diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 88bebc7..1ba33b5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -94,7 +94,7 @@ jobs: if: steps.check_config.outputs.is_configured == 'true' || github.event_name == 'workflow_dispatch' env: API_URL: ${{ secrets.API_URL || 'https://openshield-api.onrender.com' }} - JWT_SECRET: ${{ secrets.JWT_SECRET || 'change-me-in-production' }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index 83972fa..f369c42 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,4 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +ai/vectorstore/ diff --git a/README.md b/README.md index 30077a0..7ba9ae6 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,11 @@ The OpenShield API is deployed to the Render free tier and is accessible at: > **Note:** As this is hosted on the Render free tier, the service may spin down after 15 minutes of inactivity. The first request after a spin-down can take 30-60 seconds to complete. > [!IMPORTANT] -> **Security Requirement:** For absolute security, any production deployment **must** override the default `JWT_SECRET` with a strong, unique value in the environment variables. +> **Security Requirement:** Production deployments **fail at startup** if `JWT_SECRET` is missing, set to the insecure default, or shorter than 32 characters. Generate a strong secret with: +> ``` +> python -c "import secrets; print(secrets.token_urlsafe(32))" +> ``` +> Set `OPENSHIELD_ENV=production` (or rely on Render's automatic `RENDER=true`) to enable this enforcement. Local development runs without these signals are allowed to use the default with a warning. --- diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 0000000..c03af86 --- /dev/null +++ b/ai/README.md @@ -0,0 +1,34 @@ +# OpenShield RAG Pipeline + +Document loader and chunker for OpenShield rules and compliance frameworks. +Loads all scanner rules and CIS, NIST, ISO 27001 and SOC2 controls +into structured documents for the RAG vector store. + +## Files + +- `ai/loader.py` β€” loads OpenShield rules and compliance frameworks as structured documents +- `ai/chunker.py` β€” splits documents into overlapping chunks for embedding +- `ai/embed.py` β€” builds the ChromaDB vector store (from PR 97) +- `ai/retriever.py` β€” queries the vector store (from PR 97) + +## Vector Store + +The vector store is persisted at `ai/vectorstore/` using ChromaDB. + +## How loader.py works + +Reads all `scanner/rules/az_*.py` files and extracts: +- Rule ID, name, severity, category +- Description and remediation text + +Also reads all four compliance framework JSON files: +- CIS Azure Benchmark +- NIST CSF +- ISO 27001 +- SOC2 + +## How chunker.py works + +Splits documents into 512-character overlapping chunks with 64-character +overlap. Tries to split on newlines to avoid breaking mid-sentence. +Each chunk inherits the metadata of its parent document. diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1 @@ + diff --git a/ai/chunker.py b/ai/chunker.py new file mode 100644 index 0000000..8129142 --- /dev/null +++ b/ai/chunker.py @@ -0,0 +1,47 @@ +"""Chunking pipeline for OpenShield documents.""" +import logging + +logger = logging.getLogger(__name__) + +DEFAULT_CHUNK_SIZE = 512 +DEFAULT_CHUNK_OVERLAP = 64 + + +def chunk_documents(documents, chunk_size=DEFAULT_CHUNK_SIZE, chunk_overlap=DEFAULT_CHUNK_OVERLAP): + chunks = [] + for doc in documents: + doc_id = doc.get("id", "unknown") + content = doc.get("content", "") + metadata = doc.get("metadata", {}) + doc_chunks = _split_text(content, chunk_size, chunk_overlap) + for idx, chunk_text in enumerate(doc_chunks): + chunks.append({ + "id": f"{doc_id}_chunk_{idx}", + "content": chunk_text, + "metadata": {**metadata, "parent_doc_id": doc_id, "chunk_index": idx, "total_chunks": len(doc_chunks)}, + }) + logger.info("Chunked %d documents into %d chunks", len(documents), len(chunks)) + return chunks + + +def _split_text(text, chunk_size, chunk_overlap): + if len(text) <= chunk_size: + return [text] + chunks = [] + start = 0 + while start < len(text): + end = start + chunk_size + if end >= len(text): + chunks.append(text[start:].strip()) + break + split_pos = text.rfind(" +", start, end) + if split_pos == -1 or split_pos <= start: + split_pos = end + chunk = text[start:split_pos].strip() + if chunk: + chunks.append(chunk) + start = split_pos - chunk_overlap + if start < 0: + start = 0 + return [c for c in chunks if c] diff --git a/ai/embed.py b/ai/embed.py new file mode 100644 index 0000000..6725b9a --- /dev/null +++ b/ai/embed.py @@ -0,0 +1,138 @@ +"""Build the OpenShield knowledge base vector store for RAG AI insights""" + + +import importlib.util +import json +import logging +from pathlib import Path + +import chromadb + +logger = logging.getLogger(__name__) + +REPO_ROOT = Path(__file__).resolve().parent.parent +RULES_DIR = REPO_ROOT / "scanner" / "rules" +FRAMEWORKS_DIR = REPO_ROOT / "compliance" / "frameworks" +SKILLS_DIR = REPO_ROOT / "ai" / "knowledge" / "skills" +VECTORSTORE_DIR = REPO_ROOT / "ai" / "vectorstore" +COLLECTION_NAME = "openshield" + + +def _load_rule_module(path): + spec = importlib.util.spec_from_file_location(path.stem, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _collect_skill_documents(): + documents = [] + if not SKILLS_DIR.exists(): + logger.warning("Skills directory not found, skipping: %s", SKILLS_DIR) + return documents + for path in sorted(SKILLS_DIR.rglob("SKILL.md")): + try: + text = path.read_text(encoding="utf-8") + except Exception as exc: + logger.warning("Skipping %s: %s", path.name, exc) + continue + if not text.strip(): + continue + skill_name = path.parent.name + documents.append({ + "id": f"skill-{skill_name}", + "text": text, + "source": skill_name, + "type": "skill", + }) + return documents + + +def _collect_rule_documents(): + documents = [] + for path in sorted(RULES_DIR.glob("az_*.py")): + try: + module = _load_rule_module(path) + except Exception as exc: + logger.warning("Skipping %s: %s", path.name, exc) + continue + rule_id = getattr(module, "RULE_ID", None) + if not rule_id: + continue + text = ( + f"OpenShield rule {rule_id}: {getattr(module, 'RULE_NAME', '')}\n" + f"Category: {getattr(module, 'CATEGORY', '')}\n" + f"Severity: {getattr(module, 'SEVERITY', '')}\n" + f"Description: {getattr(module, 'DESCRIPTION', '')}\n" + f"Remediation: {getattr(module, 'REMEDIATION', '')}" + ) + documents.append({ + "id": f"rule-{rule_id}", + "text": text, + "source": rule_id, + "type": "rule", + }) + return documents + + +def _collect_compliance_documents(): + documents = [] + for path in sorted(FRAMEWORKS_DIR.glob("*.json")): + framework = path.stem + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Skipping %s: %s", path.name, exc) + continue + for control_id, control in data.get("controls", {}).items(): + description = control.get("description", "") + if not description: + continue + text = ( + f"{framework} control {control_id}: " + f"{control.get('control_name', '')}\n{description}" + ) + documents.append({ + "id": f"{framework}-{control_id}", + "text": text, + "source": f"{framework} {control_id}", + "type": "control", + }) + return documents + + +def build_vectorstore(): + VECTORSTORE_DIR.mkdir(parents=True, exist_ok=True) + client = chromadb.PersistentClient(path=str(VECTORSTORE_DIR)) + + try: + client.delete_collection(COLLECTION_NAME) + except Exception as exc: + logger.info("Could not delete collection '%s' before rebuild: %s", COLLECTION_NAME, exc) + collection = client.create_collection(COLLECTION_NAME) + + documents = ( + _collect_skill_documents() + + _collect_rule_documents() + + _collect_compliance_documents() + ) + if not documents: + raise RuntimeError("No documents found to embed. Check repo paths.") + + collection.add( + ids=[d["id"] for d in documents], + documents=[d["text"] for d in documents], + metadatas=[ + {"source": d["source"], "type": d["type"]} for d in documents + ], + ) + logger.info( + "Embedded %d documents into '%s'.", len(documents), COLLECTION_NAME + ) + return len(documents) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + count = build_vectorstore() + print(f"Done. Vector store built with {count} documents at {VECTORSTORE_DIR}") diff --git a/ai/knowledge/LICENSE b/ai/knowledge/LICENSE new file mode 100644 index 0000000..d885118 --- /dev/null +++ b/ai/knowledge/LICENSE @@ -0,0 +1,201 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please do not remove or change + the license header comment from a contributed file except when + necessary. + + Copyright 2026 mukul975 + + 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 + + http://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. diff --git a/ai/knowledge/skills/analyzing-azure-activity-logs-for-threats/SKILL.md b/ai/knowledge/skills/analyzing-azure-activity-logs-for-threats/SKILL.md new file mode 100644 index 0000000..10e795b --- /dev/null +++ b/ai/knowledge/skills/analyzing-azure-activity-logs-for-threats/SKILL.md @@ -0,0 +1,80 @@ +--- +name: analyzing-azure-activity-logs-for-threats +description: 'Queries Azure Monitor activity logs and sign-in logs via azure-monitor-query to detect suspicious administrative + operations, impossible travel, privilege escalation, and resource modifications. Builds KQL queries for threat hunting in + Azure environments. Use when investigating suspicious Azure tenant activity or building cloud SIEM detections. + + ' +domain: cybersecurity +subdomain: security-operations +tags: +- azure +- cloud-security +- azure-monitor +- kql +- threat-hunting +- activity-logs +version: '1.0' +author: mahipal +license: Apache-2.0 +nist_csf: +- DE.CM-01 +- RS.MA-01 +- GV.OV-01 +- DE.AE-02 +--- + +# Analyzing Azure Activity Logs for Threats + + +## When to Use + +- When investigating security incidents that require analyzing azure activity logs for threats +- When building detection rules or threat hunting queries for this domain +- When SOC analysts need structured procedures for this analysis type +- When validating security monitoring coverage for related attack techniques + +## Prerequisites + +- Familiarity with security operations concepts and tools +- Access to a test or lab environment for safe execution +- Python 3.8+ with required dependencies installed +- Appropriate authorization for any testing activities + +## Instructions + +Use azure-monitor-query to execute KQL queries against Azure Log Analytics workspaces, +detecting suspicious admin operations and sign-in anomalies. + +```python +from azure.identity import DefaultAzureCredential +from azure.monitor.query import LogsQueryClient +from datetime import timedelta + +credential = DefaultAzureCredential() +client = LogsQueryClient(credential) + +response = client.query_workspace( + workspace_id="WORKSPACE_ID", + query="AzureActivity | where OperationNameValue has 'MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE' | take 10", + timespan=timedelta(hours=24), +) +``` + +Key detection queries: +1. Role assignment changes (privilege escalation) +2. Resource group and subscription modifications +3. Key vault secret access from new IPs +4. Network security group rule changes +5. Conditional access policy modifications + +## Examples + +```python +# Detect new Global Admin role assignments +query = ''' +AuditLogs +| where OperationName == "Add member to role" +| where TargetResources[0].modifiedProperties[0].newValue has "Global Administrator" +''' +``` diff --git a/ai/knowledge/skills/analyzing-cloud-storage-access-patterns/SKILL.md b/ai/knowledge/skills/analyzing-cloud-storage-access-patterns/SKILL.md new file mode 100644 index 0000000..a614987 --- /dev/null +++ b/ai/knowledge/skills/analyzing-cloud-storage-access-patterns/SKILL.md @@ -0,0 +1,70 @@ +--- +name: analyzing-cloud-storage-access-patterns +description: Detect abnormal access patterns in AWS S3, GCS, and Azure Blob Storage by analyzing CloudTrail Data Events, GCS + audit logs, and Azure Storage Analytics. Identifies after-hours bulk downloads, access from new IP addresses, unusual API + calls (GetObject spikes), and potential data exfiltration using statistical baselines and time-series anomaly detection. +domain: cybersecurity +subdomain: cloud-security +tags: +- analyzing +- cloud +- storage +- access +version: '1.0' +author: mahipal +license: Apache-2.0 +atlas_techniques: +- AML.T0024 +- AML.T0056 +nist_ai_rmf: +- MEASURE-2.7 +- MAP-5.1 +- MANAGE-2.4 +nist_csf: +- PR.IR-01 +- ID.AM-08 +- GV.SC-06 +- DE.CM-01 +--- + + +# Analyzing Cloud Storage Access Patterns + + +## When to Use + +- When investigating security incidents that require analyzing cloud storage access patterns +- When building detection rules or threat hunting queries for this domain +- When SOC analysts need structured procedures for this analysis type +- When validating security monitoring coverage for related attack techniques + +## Prerequisites + +- Familiarity with cloud security concepts and tools +- Access to a test or lab environment for safe execution +- Python 3.8+ with required dependencies installed +- Appropriate authorization for any testing activities + +## Instructions + +1. Install dependencies: `pip install boto3 requests` +2. Query CloudTrail for S3 Data Events using AWS CLI or boto3. +3. Build access baselines: hourly request volume, per-user object counts, source IP history. +4. Detect anomalies: + - After-hours access (outside 8am-6pm local time) + - Bulk downloads: >100 GetObject calls from single principal in 1 hour + - New source IPs not seen in the prior 30 days + - ListBucket enumeration spikes (reconnaissance indicator) +5. Generate prioritized findings report. + +```bash +python scripts/agent.py --bucket my-sensitive-data --hours-back 24 --output s3_access_report.json +``` + +## Examples + +### CloudTrail S3 Data Event +```json +{"eventName": "GetObject", "requestParameters": {"bucketName": "sensitive-data", "key": "financials/q4.xlsx"}, + "sourceIPAddress": "203.0.113.50", "userIdentity": {"arn": "arn:aws:iam::123456789012:user/analyst"}} +``` diff --git a/ai/knowledge/skills/auditing-azure-active-directory-configuration/SKILL.md b/ai/knowledge/skills/auditing-azure-active-directory-configuration/SKILL.md new file mode 100644 index 0000000..77a2605 --- /dev/null +++ b/ai/knowledge/skills/auditing-azure-active-directory-configuration/SKILL.md @@ -0,0 +1,268 @@ +--- +name: auditing-azure-active-directory-configuration +description: 'Auditing Microsoft Entra ID (Azure Active Directory) configuration to identify risky authentication policies, + overly permissive role assignments, stale accounts, conditional access gaps, and guest user risks using AzureAD PowerShell, + Microsoft Graph API, and ScoutSuite. + + ' +domain: cybersecurity +subdomain: cloud-security +tags: +- cloud-security +- azure +- entra-id +- active-directory +- iam-audit +- conditional-access +version: '1.0' +author: mahipal +license: Apache-2.0 +nist_csf: +- PR.IR-01 +- ID.AM-08 +- GV.SC-06 +- DE.CM-01 +--- + +# Auditing Azure Active Directory Configuration + +## When to Use + +- When performing a security assessment of an Azure tenant's identity configuration +- When compliance audits require review of authentication policies, MFA enforcement, and role assignments +- When onboarding a new Azure tenant after merger or acquisition +- When investigating suspicious sign-in activity or compromised accounts +- When validating conditional access policies adequately protect against identity-based attacks + +**Do not use** for on-premises Active Directory auditing (use PingCastle or BloodHound AD), for Azure resource-level RBAC auditing without identity context, or for real-time threat detection (use Microsoft Defender for Identity). + +## Prerequisites + +- Global Reader or Security Reader role in the target Microsoft Entra ID tenant +- Microsoft Graph PowerShell SDK installed (`Install-Module Microsoft.Graph`) +- Az CLI authenticated to the target tenant (`az login --tenant TENANT_ID`) +- ScoutSuite with Azure provider configured for automated assessment +- Access to Azure AD audit logs and sign-in logs (requires Azure AD Premium P1/P2) + +## Workflow + +### Step 1: Enumerate Tenant Configuration and Security Defaults + +Assess the tenant's baseline identity security settings including security defaults and legacy authentication status. + +```powershell +# Connect to Microsoft Graph +Connect-MgGraph -Scopes "Directory.Read.All","Policy.Read.All","AuditLog.Read.All" + +# Get tenant details +Get-MgOrganization | Select-Object DisplayName, Id, VerifiedDomains + +# Check if Security Defaults are enabled +Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy | Select-Object IsEnabled + +# List authentication methods policies +Get-MgPolicyAuthenticationMethodPolicy | ConvertTo-Json -Depth 5 + +# Check legacy authentication status via Conditional Access +Get-MgIdentityConditionalAccessPolicy | Where-Object { + $_.Conditions.ClientAppTypes -contains "exchangeActiveSync" -or + $_.Conditions.ClientAppTypes -contains "other" +} | Select-Object DisplayName, State +``` + +### Step 2: Audit Privileged Role Assignments + +Review directory role assignments to identify over-privileged users, permanent admin accounts, and risky role configurations. + +```bash +# List all Global Administrator assignments +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/directoryRoles/filterByIds" \ + --body '{"ids":["62e90394-69f5-4237-9190-012177145e10"]}' | \ + az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/directoryRoles?filter=displayName eq 'Global Administrator'" \ + --query "value[0].id" -o tsv + +# List all privileged role assignments using Graph API +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$expand=principal" \ + --query "value[*].{Role:roleDefinitionId, Principal:principal.displayName, PrincipalType:principal.@odata.type}" \ + -o table + +# Check for users with multiple admin roles +az ad user list --query "[].{UPN:userPrincipalName, DisplayName:displayName}" -o table + +# List service principals with admin role assignments +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=principalOrganizationId eq 'TENANT_ID'" \ + -o json +``` + +### Step 3: Review Conditional Access Policies + +Audit conditional access policies for coverage gaps, particularly around MFA enforcement, device compliance, and location-based restrictions. + +```powershell +# List all Conditional Access policies +Get-MgIdentityConditionalAccessPolicy | Select-Object DisplayName, State, @{ + N='GrantControls'; E={$_.GrantControls.BuiltInControls -join ', '} +} | Format-Table -AutoSize + +# Identify policies in report-only mode (not enforced) +Get-MgIdentityConditionalAccessPolicy | Where-Object {$_.State -eq "enabledForReportingButNotEnforced"} | + Select-Object DisplayName + +# Check MFA enforcement coverage +Get-MgIdentityConditionalAccessPolicy | Where-Object { + $_.GrantControls.BuiltInControls -contains "mfa" +} | Select-Object DisplayName, State, @{ + N='Users'; E={$_.Conditions.Users.IncludeUsers -join ', '} +} + +# Find policies that exclude groups (potential bypass) +Get-MgIdentityConditionalAccessPolicy | Where-Object { + $_.Conditions.Users.ExcludeGroups.Count -gt 0 +} | Select-Object DisplayName, @{ + N='ExcludedGroups'; E={$_.Conditions.Users.ExcludeGroups -join ', '} +} +``` + +### Step 4: Identify Stale Accounts and Guest Users + +Find accounts that have not signed in recently, disabled accounts with active role assignments, and risky guest user configurations. + +```bash +# Find users who haven't signed in for 90+ days +az ad user list --query "[?signInActivity.lastSignInDateTime < '2025-11-25T00:00:00Z'].{UPN:userPrincipalName, LastSignIn:signInActivity.lastSignInDateTime, Enabled:accountEnabled}" -o table + +# List all guest users +az ad user list --filter "userType eq 'Guest'" \ + --query "[].{UPN:userPrincipalName, DisplayName:displayName, CreatedDate:createdDateTime}" \ + -o table + +# Find guest users with privileged roles +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$expand=principal" \ + --query "value[?principal.userType=='Guest'].{Role:roleDefinitionId,Guest:principal.userPrincipalName}" \ + -o table + +# Check for accounts with disabled MFA +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails" \ + --query "value[?!isMfaRegistered].{UPN:userPrincipalName,MfaRegistered:isMfaRegistered}" \ + -o table +``` + +### Step 5: Analyze Sign-In Logs for Risky Activity + +Review sign-in logs to identify anomalous authentication patterns, failed MFA challenges, and risky sign-in detections. + +```bash +# Get risky sign-ins from last 7 days +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=riskLevelDuringSignIn ne 'none' and createdDateTime ge 2026-02-16T00:00:00Z" \ + --query "value[*].{User:userPrincipalName,Risk:riskLevelDuringSignIn,IP:ipAddress,App:appDisplayName,Status:status.errorCode}" \ + -o table + +# Get sign-ins from unfamiliar locations +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=riskEventTypes_v2/any(r:r eq 'unfamiliarFeatures')" \ + --query "value[*].{User:userPrincipalName,Location:location.city,IP:ipAddress}" \ + -o table + +# Check for legacy authentication sign-ins +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=clientAppUsed ne 'Browser' and clientAppUsed ne 'Mobile Apps and Desktop clients'" \ + --query "value[*].{User:userPrincipalName,ClientApp:clientAppUsed,Status:status.errorCode}" \ + -o table +``` + +### Step 6: Run ScoutSuite Automated Assessment + +Execute ScoutSuite for comprehensive automated checks across the Azure tenant configuration. + +```bash +# Run ScoutSuite against Azure +python3 -m ScoutSuite azure --cli \ + --report-dir ./scoutsuite-azure-report \ + --all-subscriptions + +# Review the generated HTML report +open ./scoutsuite-azure-report/azure-report.html +``` + +## Key Concepts + +| Term | Definition | +|------|------------| +| Microsoft Entra ID | Microsoft's cloud identity and access management service, formerly Azure Active Directory, providing authentication and authorization | +| Conditional Access | Policy engine that evaluates signals (user, device, location, risk) to enforce access controls like MFA, device compliance, or block access | +| Security Defaults | Microsoft's baseline identity protection settings that enforce MFA registration, block legacy auth, and protect privileged actions | +| Privileged Identity Management | Azure AD Premium P2 feature enabling just-in-time privileged access with approval workflows and time-bound role activation | +| Legacy Authentication | Older authentication protocols (POP3, IMAP, SMTP, ActiveSync) that do not support MFA and are commonly exploited for credential attacks | +| Risky Sign-In | Microsoft Entra Identity Protection detection of sign-in anomalies including impossible travel, unfamiliar locations, and malware-linked IPs | + +## Tools & Systems + +- **Microsoft Graph API**: Primary programmatic interface for querying Entra ID configuration, policies, roles, and audit logs +- **Microsoft Graph PowerShell SDK**: PowerShell module for Entra ID management and security auditing tasks +- **ScoutSuite**: Multi-cloud auditing tool with Azure provider support for IAM, storage, networking, and identity checks +- **AzureADRecon**: Community tool for comprehensive Azure AD reconnaissance and security assessment reporting +- **Microsoft Defender for Identity**: Cloud-based security solution for detecting identity-based threats and compromised credentials + +## Common Scenarios + +### Scenario: Post-Acquisition Azure Tenant Security Assessment + +**Context**: After acquiring a company, the security team needs to assess the Azure tenant identity posture before integrating it with the corporate Entra ID. + +**Approach**: +1. Enumerate all Global Administrators and check for personal accounts in admin roles +2. Review conditional access policies to verify MFA is enforced for all users, not just admins +3. Identify guest users with privileged access that may indicate third-party vendor over-permissioning +4. Check for stale accounts (no sign-in for 90+ days) that could be targets for credential attacks +5. Review sign-in logs for legacy authentication usage that bypasses MFA +6. Verify Security Defaults or equivalent CA policies block legacy auth protocols +7. Produce a risk report with prioritized remediation steps before tenant integration + +**Pitfalls**: Azure AD Premium P2 is required for risky sign-in detections and PIM. If the acquired tenant uses a lower license tier, many identity protection features will be unavailable. Guest users from partner tenants may have implicit access through dynamic groups that are not visible in standard role assignment queries. + +## Output Format + +``` +Azure Active Directory Security Audit Report +=============================================== +Tenant: acme-acquired.onmicrosoft.com +Tenant ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +Audit Date: 2026-02-23 +License: Azure AD Premium P2 + +IDENTITY CONFIGURATION: + Security Defaults: Disabled (Conditional Access in use) + Conditional Access Policies: 12 (8 enforced, 3 report-only, 1 disabled) + Legacy Auth Blocked: Partial (blocked for admins only) + +PRIVILEGED ACCESS: + Global Administrators: 8 (recommended: <= 4) + Permanent admin assignments: 6 (no PIM activation required) + Service principals with admin: 3 + Guest users with privileged roles: 2 + +ACCOUNT HYGIENE: + Total users: 1,247 + Stale accounts (90+ days): 89 + Guest users: 234 + Users without MFA registered: 156 + +SIGN-IN RISK: + Risky sign-ins (last 30 days): 34 + Legacy auth sign-ins (last 7 days): 67 + Impossible travel detections: 5 + Unfamiliar location sign-ins: 12 + +CRITICAL FINDINGS: + 1. 8 Global Administrators with permanent assignments (use PIM) + 2. Legacy authentication not blocked for non-admin users + 3. 156 users without MFA registration + 4. 2 guest users with Privileged Role Administrator role +``` diff --git a/ai/knowledge/skills/auditing-cloud-with-cis-benchmarks/SKILL.md b/ai/knowledge/skills/auditing-cloud-with-cis-benchmarks/SKILL.md new file mode 100644 index 0000000..a333f28 --- /dev/null +++ b/ai/knowledge/skills/auditing-cloud-with-cis-benchmarks/SKILL.md @@ -0,0 +1,264 @@ +--- +name: auditing-cloud-with-cis-benchmarks +description: 'This skill details how to conduct cloud security audits using Center for Internet Security benchmarks for AWS, + Azure, and GCP. It covers interpreting CIS Foundations Benchmark controls, running automated assessments with tools like + Prowler and ScoutSuite, remediating failed controls, and maintaining continuous compliance monitoring against CIS v5 for + AWS, v4 for Azure, and v4 for GCP. + + ' +domain: cybersecurity +subdomain: cloud-security +tags: +- cis-benchmarks +- cloud-audit +- compliance-assessment +- prowler +- security-hardening +version: 1.0.0 +author: mahipal +license: Apache-2.0 +nist_ai_rmf: +- GOVERN-1.1 +- GOVERN-4.2 +- MAP-2.3 +nist_csf: +- PR.IR-01 +- ID.AM-08 +- GV.SC-06 +- DE.CM-01 +--- + +# Auditing Cloud with CIS Benchmarks + +## When to Use + +- When performing initial security audits of cloud environments against industry-standard benchmarks +- When preparing for SOC 2, ISO 27001, or regulatory audits that reference CIS controls +- When establishing a measurable security baseline for new cloud accounts or subscriptions +- When tracking compliance improvement over time with periodic reassessment +- When evaluating the security posture of acquired or inherited cloud environments + +**Do not use** for runtime threat detection (see detecting-cloud-threats-with-guardduty), for application-level security testing (see conducting-cloud-penetration-testing), or for compliance frameworks not based on CIS (refer to specific regulatory skill files). + +## Prerequisites + +- Read-only access to target cloud accounts (AWS SecurityAudit policy, Azure Reader role, GCP Viewer role) +- Prowler, ScoutSuite, or cloud-native CSPM tools installed and configured +- Understanding of CIS benchmark structure: sections, controls, profiles (Level 1 and Level 2) +- Remediation access for implementing fixes (separate from audit credentials) + +## Workflow + +### Step 1: Select Appropriate CIS Benchmark Version + +Choose the correct benchmark version for each cloud provider. Current versions as of 2025 include CIS AWS Foundations Benchmark v5.0, CIS Azure Foundations Benchmark v4.0, and CIS GCP Foundations Benchmark v4.0. + +``` +CIS Benchmark Coverage Areas: ++-------------------+-------------------------+------------------------+ +| Section | AWS v5.0 | Azure v4.0 | ++-------------------+-------------------------+------------------------+ +| Identity & Access | IAM policies, MFA, root | Azure AD, RBAC, PIM | +| Logging | CloudTrail, Config | Activity Log, Diag | +| Monitoring | CloudWatch alarms | Defender, Sentinel | +| Networking | VPC, SG, NACLs | NSG, ASG, Firewall | +| Storage | S3 encryption, access | Storage encryption | +| Database | RDS encryption | SQL TDE, auditing | ++-------------------+-------------------------+------------------------+ + +CIS Profile Levels: + Level 1: Practical security settings that can be implemented without significant + performance impact or reduced functionality + Level 2: Defense-in-depth settings that may reduce functionality or require + additional planning for implementation +``` + +### Step 2: Run Automated Assessment with Prowler + +Execute comprehensive CIS benchmark scans using Prowler for automated control evaluation across AWS, Azure, and GCP. + +```bash +# AWS CIS v5.0 assessment +prowler aws \ + --compliance cis_5.0_aws \ + --profile audit-account \ + --output-formats json-ocsf,html,csv \ + --output-directory ./cis-audit-$(date +%Y%m%d) + +# Azure CIS v4.0 assessment +prowler azure \ + --compliance cis_4.0_azure \ + --subscription-ids "sub-id-1,sub-id-2" \ + --output-formats json-ocsf,html,csv \ + --output-directory ./cis-audit-azure-$(date +%Y%m%d) + +# GCP CIS v4.0 assessment +prowler gcp \ + --compliance cis_4.0_gcp \ + --project-ids "project-1,project-2" \ + --output-formats json-ocsf,html,csv \ + --output-directory ./cis-audit-gcp-$(date +%Y%m%d) + +# Multi-account AWS scan using ScoutSuite +scout suite aws \ + --profile audit-account \ + --report-dir ./scout-report \ + --ruleset cis-5.0 \ + --force +``` + +### Step 3: Interpret Results and Prioritize Remediation + +Analyze audit results by section and severity. Prioritize Level 1 controls first as they represent fundamental security hygiene, then address Level 2 controls for defense in depth. + +```bash +# Parse Prowler results for failed controls +cat ./cis-audit-*/prowler-output-*.json | \ + jq '[.[] | select(.StatusExtended == "FAIL")] | group_by(.CheckID) | + map({control: .[0].CheckID, description: .[0].CheckTitle, + failed_resources: length, severity: .[0].Severity}) | + sort_by(-.failed_resources)' + +# Generate compliance score by section +cat ./cis-audit-*/prowler-output-*.json | \ + jq 'group_by(.Section) | map({ + section: .[0].Section, + total: length, + passed: [.[] | select(.StatusExtended == "PASS")] | length, + failed: [.[] | select(.StatusExtended == "FAIL")] | length, + score: (([.[] | select(.StatusExtended == "PASS")] | length) / length * 100 | round) + })' +``` + +### Step 4: Remediate Critical and High Controls + +Address failed controls starting with the highest impact items. Use AWS Config remediation, Azure Policy, or Terraform to apply fixes systematically. + +```bash +# CIS 1.4: Ensure no root account access key exists +aws iam list-access-keys --user-name root +# If keys exist, delete them +aws iam delete-access-key --user-name root --access-key-id AKIAEXAMPLE + +# CIS 2.1.1: Ensure S3 bucket default encryption is enabled +for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do + aws s3api put-bucket-encryption --bucket "$bucket" \ + --server-side-encryption-configuration '{ + "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}] + }' 2>/dev/null && echo "Encrypted: $bucket" || echo "FAILED: $bucket" +done + +# CIS 3.1: Ensure CloudTrail is enabled in all regions +aws cloudtrail create-trail \ + --name organization-trail \ + --s3-bucket-name cloudtrail-logs-bucket \ + --is-multi-region-trail \ + --enable-log-file-validation \ + --kms-key-id arn:aws:kms:us-east-1:123456789012:key/key-id + +aws cloudtrail start-logging --name organization-trail + +# CIS 4.x: Configure CloudWatch metric filters and alarms +aws logs put-metric-filter \ + --log-group-name CloudTrail/DefaultLogGroup \ + --filter-name UnauthorizedAPICalls \ + --filter-pattern '{ ($.errorCode = "*UnauthorizedAccess*") || ($.errorCode = "AccessDenied*") }' \ + --metric-transformations metricName=UnauthorizedAPICalls,metricNamespace=CISBenchmark,metricValue=1 +``` + +### Step 5: Establish Continuous Compliance Monitoring + +Deploy automated compliance monitoring to detect configuration drift between periodic audits. Use AWS Security Hub, Azure Policy, or GCP Security Command Center. + +```bash +# AWS: Enable CIS v5.0 in Security Hub +aws securityhub batch-enable-standards \ + --standards-subscription-requests '[ + {"StandardsArn": "arn:aws:securityhub:::ruleset/cis-aws-foundations-benchmark/v/5.0.0"} + ]' + +# Azure: Assign CIS benchmark policy initiative +az policy assignment create \ + --name cis-azure-benchmark \ + --scope "/subscriptions/" \ + --policy-set-definition "1a5bb27d-173f-493e-9568-eb56638dbd0e" \ + --params '{"effect": {"value": "AuditIfNotExists"}}' + +# Schedule periodic Prowler assessments +# Run weekly via cron or CI/CD pipeline +0 2 * * 1 prowler aws --compliance cis_5.0_aws --output-formats csv --output-directory /opt/audits/weekly-$(date +\%Y\%m\%d) +``` + +## Key Concepts + +| Term | Definition | +|------|------------| +| CIS Benchmark | Prescriptive security configuration guidelines developed by the Center for Internet Security through community consensus | +| Level 1 Profile | Practical security controls implementable without significant performance or functionality impact, representing security hygiene | +| Level 2 Profile | Defense-in-depth controls that may restrict functionality and require careful planning before implementation | +| Foundations Benchmark | CIS benchmark specifically for cloud providers covering IAM, logging, monitoring, networking, and storage security | +| Control ID | Unique numerical identifier for each CIS recommendation (e.g., 1.4 for root access key checks, 2.1.1 for S3 encryption) | +| Compliance Score | Percentage of CIS controls in a passing state, tracked over time to measure security posture improvement | +| Automated Assessment | Tool-driven evaluation of CIS controls using cloud provider APIs to check resource configurations against benchmark requirements | +| Remediation Runbook | Documented step-by-step procedure for fixing a specific failed CIS control, including pre-checks and validation | + +## Tools & Systems + +- **Prowler**: Open-source cloud security tool performing 300+ checks including CIS benchmark assessments for AWS, Azure, and GCP +- **ScoutSuite**: Multi-cloud security auditing tool with CIS benchmark rule sets generating HTML reports +- **AWS Security Hub**: Native AWS service supporting CIS AWS Foundations Benchmark as a security standard +- **Azure Policy**: Governance service with built-in CIS benchmark policy initiatives for automated compliance monitoring +- **GCP Security Command Center**: Native GCP service evaluating configurations against CIS GCP Foundations Benchmark + +## Common Scenarios + +### Scenario: Pre-Audit CIS Assessment for SOC 2 Certification + +**Context**: A SaaS company pursuing SOC 2 Type II certification needs to demonstrate cloud security controls aligned to CIS benchmarks. The auditor requires evidence of continuous compliance monitoring across 45 AWS accounts. + +**Approach**: +1. Run Prowler CIS v5.0 assessment across all 45 accounts to establish the baseline compliance score +2. Export results to CSV and categorize failures by section (IAM, Logging, Monitoring, Networking) +3. Map each CIS control to the relevant SOC 2 Trust Services Criteria (CC6.1, CC6.6, CC7.1, etc.) +4. Remediate all Level 1 control failures within 30 days and Level 2 within 60 days +5. Enable CIS v5.0 in AWS Security Hub for continuous monitoring and automated drift detection +6. Generate weekly compliance reports showing improvement trajectory for the auditor +7. Document exceptions for controls intentionally not implemented with risk acceptance justification + +**Pitfalls**: Remediating controls without testing in a staging environment first can break production workloads. Ignoring Level 2 controls entirely weakens the audit narrative even if they are not strictly required. + +## Output Format + +``` +CIS Benchmark Audit Report +============================ +Cloud Provider: AWS +Benchmark Version: CIS AWS Foundations Benchmark v5.0 +Accounts Assessed: 45 +Assessment Date: 2025-02-23 +Tool: Prowler v4.3.0 + +OVERALL COMPLIANCE SCORE: 74% + +COMPLIANCE BY SECTION: + 1. Identity and Access Management: 68% (41/60 controls passed) + 2. Storage: 82% (28/34 controls passed) + 3. Logging: 91% (20/22 controls passed) + 4. Monitoring: 55% (18/33 controls passed) + 5. Networking: 78% (32/41 controls passed) + +TOP FAILED CONTROLS (by affected accounts): + [1.4] Root account has active access keys - 3/45 accounts + [1.5] MFA not enabled for root account - 2/45 accounts + [2.1.1] S3 default encryption not enabled - 12/45 accounts + [3.1] CloudTrail not multi-region - 8/45 accounts + [4.3] No alarm for root account usage - 28/45 accounts + [5.1] VPC flow logs not enabled - 15/45 accounts + [5.4] Security groups allow 0.0.0.0/0 ingress - 22/45 accounts + +REMEDIATION PRIORITY: + Critical (Fix within 7 days): Root access keys, missing root MFA + High (Fix within 30 days): S3 encryption, CloudTrail, VPC flow logs + Medium (Fix within 60 days): CloudWatch alarms, security group restrictions + Low (Fix within 90 days): Level 2 controls, informational items +``` diff --git a/ai/knowledge/skills/building-cloud-siem-with-sentinel/SKILL.md b/ai/knowledge/skills/building-cloud-siem-with-sentinel/SKILL.md new file mode 100644 index 0000000..878c242 --- /dev/null +++ b/ai/knowledge/skills/building-cloud-siem-with-sentinel/SKILL.md @@ -0,0 +1,317 @@ +--- +name: building-cloud-siem-with-sentinel +description: 'This skill covers deploying Microsoft Sentinel as a cloud-native SIEM and SOAR platform for centralized security + operations. It details configuring data connectors for multi-cloud log ingestion, writing KQL detection queries, building + automated response playbooks with Logic Apps, and leveraging the Sentinel data lake for petabyte-scale threat hunting across + AWS, Azure, and GCP security telemetry. + + ' +domain: cybersecurity +subdomain: cloud-security +tags: +- microsoft-sentinel +- cloud-siem +- kql-queries +- soar-automation +- threat-detection +version: 1.0.0 +author: mahipal +license: Apache-2.0 +nist_ai_rmf: +- MEASURE-2.7 +- MAP-5.1 +- MANAGE-2.4 +atlas_techniques: +- AML.T0070 +- AML.T0066 +- AML.T0082 +nist_csf: +- PR.IR-01 +- ID.AM-08 +- GV.SC-06 +- DE.CM-01 +--- + +# Building Cloud SIEM with Sentinel + +## When to Use + +- When establishing a centralized security operations center for multi-cloud environments +- When migrating from legacy SIEM platforms (Splunk, QRadar) to cloud-native architecture +- When building automated incident response workflows for cloud-specific threats +- When performing large-scale threat hunting across petabytes of security telemetry +- When integrating threat intelligence feeds with cloud security log analysis + +**Do not use** for AWS-only environments where Security Hub and GuardDuty suffice, for endpoint detection requiring EDR capabilities (use Defender for Endpoint), or for compliance posture monitoring (see building-cloud-security-posture-management). + +## Prerequisites + +- Azure subscription with Microsoft Sentinel enabled on a Log Analytics workspace +- Data connector permissions for target log sources (AWS CloudTrail, Azure Activity, GCP) +- Logic Apps or Azure Functions for automated response playbooks +- KQL (Kusto Query Language) proficiency for writing detection rules and hunting queries + +## Workflow + +### Step 1: Provision Sentinel Workspace and Data Connectors + +Create a Log Analytics workspace optimized for security data and enable data connectors for multi-cloud ingestion. + +```powershell +# Create Log Analytics workspace +az monitor log-analytics workspace create \ + --resource-group security-rg \ + --workspace-name sentinel-workspace \ + --location eastus \ + --retention-time 365 \ + --sku PerGB2018 + +# Enable Microsoft Sentinel on the workspace +az sentinel onboarding-state create \ + --resource-group security-rg \ + --workspace-name sentinel-workspace + +# Enable AWS CloudTrail connector +az sentinel data-connector create \ + --resource-group security-rg \ + --workspace-name sentinel-workspace \ + --data-connector-id aws-cloudtrail \ + --kind AmazonWebServicesCloudTrail \ + --aws-cloud-trail-data-connector '{ + "awsRoleArn": "arn:aws:iam::123456789012:role/SentinelCloudTrailRole", + "dataTypes": {"logs": {"state": "Enabled"}} + }' + +# Enable Azure AD sign-in and audit logs +az sentinel data-connector create \ + --resource-group security-rg \ + --workspace-name sentinel-workspace \ + --data-connector-id azure-ad \ + --kind AzureActiveDirectory \ + --azure-active-directory '{ + "dataTypes": { + "alerts": {"state": "Enabled"}, + "signinLogs": {"state": "Enabled"}, + "auditLogs": {"state": "Enabled"} + } + }' +``` + +### Step 2: Write KQL Detection Rules + +Create analytics rules using Kusto Query Language to detect cloud-specific threats. Map each rule to MITRE ATT&CK techniques. + +```kql +// Detect impossible travel - sign-ins from geographically distant locations +let timeframe = 1h; +let distance_threshold = 500; // km +SigninLogs +| where TimeGenerated > ago(timeframe) +| where ResultType == 0 // Successful sign-ins only +| project TimeGenerated, UserPrincipalName, IPAddress, Location, + Latitude = toreal(LocationDetails.geoCoordinates.latitude), + Longitude = toreal(LocationDetails.geoCoordinates.longitude) +| sort by UserPrincipalName asc, TimeGenerated asc +| extend PrevLatitude = prev(Latitude, 1), PrevLongitude = prev(Longitude, 1), + PrevTime = prev(TimeGenerated, 1), PrevUser = prev(UserPrincipalName, 1) +| where UserPrincipalName == PrevUser +| extend TimeDiff = datetime_diff('minute', TimeGenerated, PrevTime) +| where TimeDiff < 60 +| extend Distance = geo_distance_2points(Longitude, Latitude, PrevLongitude, PrevLatitude) / 1000 +| where Distance > distance_threshold +| project TimeGenerated, UserPrincipalName, IPAddress, Location, Distance, TimeDiff +``` + +```kql +// Detect AWS IAM credential abuse from CloudTrail +AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventName in ("ConsoleLogin", "AssumeRole", "GetSessionToken") +| where ErrorCode == "" +| summarize LoginCount = count(), DistinctIPs = dcount(SourceIpAddress), + IPList = make_set(SourceIpAddress, 10) + by UserIdentityArn, bin(TimeGenerated, 1h) +| where DistinctIPs > 3 +| project TimeGenerated, UserIdentityArn, LoginCount, DistinctIPs, IPList +``` + +```kql +// Detect mass S3 object deletion (potential ransomware) +AWSCloudTrail +| where TimeGenerated > ago(1h) +| where EventName == "DeleteObject" or EventName == "DeleteObjects" +| summarize DeleteCount = count(), BucketsAffected = dcount(RequestParameters_bucketName) + by UserIdentityArn, bin(TimeGenerated, 10m) +| where DeleteCount > 100 +| project TimeGenerated, UserIdentityArn, DeleteCount, BucketsAffected +``` + +### Step 3: Build SOAR Playbooks with Logic Apps + +Create automated response playbooks that execute when analytics rules trigger incidents. Common actions include blocking users, isolating resources, and enriching alerts with threat intelligence. + +```json +{ + "definition": { + "triggers": { + "Microsoft_Sentinel_incident": { + "type": "ApiConnectionWebhook", + "inputs": { + "body": {"incidentArmId": "subscriptions/@{triggerBody()?['workspaceInfo']?['SubscriptionId']}/resourceGroups/@{triggerBody()?['workspaceInfo']?['ResourceGroupName']}/providers/Microsoft.OperationalInsights/workspaces/@{triggerBody()?['workspaceInfo']?['WorkspaceName']}/providers/Microsoft.SecurityInsights/Incidents/@{triggerBody()?['object']?['properties']?['incidentNumber']}"}, + "host": {"connection": {"name": "@parameters('$connections')['microsoftsentinel']['connectionId']"}} + } + } + }, + "actions": { + "Get_incident_entities": { + "type": "ApiConnection", + "inputs": {"method": "post", "path": "/Incidents/entities"} + }, + "For_each_account_entity": { + "type": "Foreach", + "foreach": "@body('Get_incident_entities')?['Accounts']", + "actions": { + "Disable_Azure_AD_user": { + "type": "ApiConnection", + "inputs": { + "method": "PATCH", + "path": "/v1.0/users/@{items('For_each_account_entity')?['AadUserId']}", + "body": {"accountEnabled": false} + } + }, + "Add_comment_to_incident": { + "type": "ApiConnection", + "inputs": { + "body": {"message": "User @{items('For_each_account_entity')?['Name']} disabled by automated playbook"} + } + } + } + } + } + } +} +``` + +### Step 4: Configure Sentinel Data Lake for Long-Term Hunting + +Enable the Sentinel data lake for petabyte-scale log retention and advanced threat hunting using both KQL and SQL endpoints. + +```kql +// Threat hunting query: detect lateral movement across AWS accounts +let suspicious_roles = AWSCloudTrail +| where TimeGenerated > ago(7d) +| where EventName == "AssumeRole" +| extend AssumedRoleArn = tostring(parse_json(RequestParameters).roleArn) +| where AssumedRoleArn contains "cross-account" or AssumedRoleArn contains "admin" +| summarize AssumeCount = count(), UniqueSourceAccounts = dcount(RecipientAccountId) + by UserIdentityArn, AssumedRoleArn +| where AssumeCount > 10 and UniqueSourceAccounts > 2; +suspicious_roles +| join kind=inner ( + AWSCloudTrail + | where TimeGenerated > ago(7d) + | where EventName in ("RunInstances", "CreateFunction", "PutBucketPolicy") +) on UserIdentityArn +| project TimeGenerated, UserIdentityArn, AssumedRoleArn, EventName, SourceIpAddress +``` + +### Step 5: Integrate Threat Intelligence + +Connect threat intelligence providers and create indicator-based matching rules to detect communication with known malicious infrastructure. + +```powershell +# Enable Microsoft Threat Intelligence connector +az sentinel data-connector create \ + --resource-group security-rg \ + --workspace-name sentinel-workspace \ + --data-connector-id microsoft-ti \ + --kind MicrosoftThreatIntelligence \ + --microsoft-threat-intelligence '{ + "dataTypes": {"microsoftEmergingThreatFeed": {"lookbackPeriod": "2025-01-01T00:00:00Z", "state": "Enabled"}} + }' +``` + +```kql +// Match network indicators against cloud flow logs +let TI_IPs = ThreatIntelligenceIndicator +| where TimeGenerated > ago(30d) +| where isnotempty(NetworkIP) +| distinct NetworkIP; +AzureNetworkAnalytics_CL +| where TimeGenerated > ago(24h) +| where DestIP_s in (TI_IPs) +| project TimeGenerated, SrcIP_s, DestIP_s, DestPort_d, FlowType_s +``` + +## Key Concepts + +| Term | Definition | +|------|------------| +| KQL | Kusto Query Language, the primary query language for Microsoft Sentinel used to search, analyze, and visualize security data | +| Analytics Rule | Detection logic in Sentinel that evaluates log data on a schedule and creates incidents when conditions match | +| SOAR Playbook | Automated workflow triggered by incidents that performs response actions such as blocking accounts, enriching alerts, or notifying teams | +| Data Connector | Integration module that ingests security logs from cloud services, identity providers, and third-party tools into Sentinel | +| Sentinel Data Lake | Petabyte-scale storage layer providing long-term log retention with KQL and SQL query interfaces for advanced hunting | +| Workbook | Interactive dashboard in Sentinel displaying visualizations of security data, trends, and operational metrics | +| Watchlist | Reference data tables in Sentinel used to enrich alerts with context such as VIP user lists or approved IP ranges | +| Fusion Detection | Machine learning-powered correlation engine that automatically detects multi-stage attacks across data sources | + +## Tools & Systems + +- **Microsoft Sentinel**: Cloud-native SIEM/SOAR platform built on Azure Log Analytics with AI-powered threat detection +- **Azure Logic Apps**: Low-code automation platform for building SOAR playbooks triggered by Sentinel incidents +- **Microsoft Threat Intelligence**: Integrated threat feeds providing IP, domain, and URL indicators for matching against security logs +- **Azure Data Explorer**: High-performance analytics engine underlying Sentinel KQL queries for large-scale data exploration +- **MITRE ATT&CK Navigator**: Framework for mapping Sentinel detection rules to adversary tactics and techniques + +## Common Scenarios + +### Scenario: Detecting Cross-Cloud Credential Theft Campaign + +**Context**: An attacker compromises an Azure AD account through phishing, then uses the account to access AWS resources via federated identity. Sentinel needs to correlate the Azure sign-in anomaly with unusual AWS API activity. + +**Approach**: +1. Create an analytics rule detecting Azure AD impossible travel or anomalous sign-in risk +2. Write a KQL query correlating the compromised Azure AD identity with AWS CloudTrail AssumeRoleWithSAML events +3. Build a Fusion detection rule that links Azure AD risk events with subsequent AWS privilege escalation activity +4. Deploy a SOAR playbook that automatically disables the Azure AD account and revokes AWS STS sessions +5. Create a workbook showing the timeline from initial compromise through lateral movement to AWS +6. Run a hunting query across the data lake to check for similar patterns affecting other accounts + +**Pitfalls**: Not correlating identity across cloud providers misses the full attack chain. Setting analytics rule frequency too low (e.g., 24 hours) allows attackers hours of undetected access. + +## Output Format + +``` +Microsoft Sentinel SOC Operations Report +========================================== +Workspace: sentinel-workspace +Data Sources: 14 connectors active +Report Period: 2025-02-01 to 2025-02-23 + +DATA INGESTION: + Azure AD Sign-in Logs: 2.3 TB (23 days) + AWS CloudTrail: 1.8 TB (23 days) + Azure Activity: 0.9 TB (23 days) + Defender for Cloud Alerts: 45 GB (23 days) + Total Ingestion: 5.1 TB + +DETECTION SUMMARY: + Active Analytics Rules: 87 + Incidents Created: 234 + Critical: 8 | High: 34 | Medium: 89 | Low: 103 + Mean Time to Detect (MTTD): 4.2 minutes + Mean Time to Respond (MTTR): 18 minutes + +TOP INCIDENT TYPES: + Impossible Travel Detected: 42 incidents + AWS Unauthorized API Call Pattern: 28 incidents + Mass File Deletion in S3: 3 incidents + Suspicious Azure AD App Registration: 12 incidents + +AUTOMATION: + Playbooks Executed: 156 + Accounts Auto-Disabled: 23 + Incidents Auto-Enriched: 198 + False Positive Rate: 12% +``` diff --git a/ai/knowledge/skills/building-identity-federation-with-saml-azure-ad/SKILL.md b/ai/knowledge/skills/building-identity-federation-with-saml-azure-ad/SKILL.md new file mode 100644 index 0000000..b570845 --- /dev/null +++ b/ai/knowledge/skills/building-identity-federation-with-saml-azure-ad/SKILL.md @@ -0,0 +1,232 @@ +--- +name: building-identity-federation-with-saml-azure-ad +description: Establish SAML 2.0 identity federation between on-premises Active Directory and Azure AD (Microsoft Entra ID) + for seamless cross-domain authentication and SSO to cloud applications. +domain: cybersecurity +subdomain: identity-access-management +tags: +- saml +- azure-ad +- entra-id +- federation +- identity +- sso +- adfs +- hybrid-identity +version: '1.0' +author: mahipal +license: Apache-2.0 +nist_csf: +- PR.AA-01 +- PR.AA-02 +- PR.AA-05 +- PR.AA-06 +--- + +# Building Identity Federation with SAML Azure AD + +## Overview + +Identity federation enables users authenticated by one identity provider to access resources managed by another without maintaining separate credentials. This skill covers establishing SAML 2.0 federation between an organization's on-premises Active Directory (via AD FS or third-party IdP) and Microsoft Entra ID (formerly Azure AD), as well as configuring federated SSO for third-party SaaS applications. Federation eliminates password synchronization concerns and keeps authentication authority on-premises while extending SSO to cloud resources. + + +## When to Use + +- When deploying or configuring building identity federation with saml azure ad capabilities in your environment +- When establishing security controls aligned to compliance requirements +- When building or improving security architecture for this domain +- When conducting security assessments that require this implementation + +## Prerequisites + +- On-premises Active Directory domain +- AD FS 2019+ or third-party SAML IdP (Okta, Ping, etc.) +- Microsoft Entra ID tenant (P1 or P2 license recommended) +- Azure AD Connect (if using hybrid identity with password hash sync as backup) +- Public TLS certificate for federation endpoint +- DNS records for federation service name + +## Core Concepts + +### Federation Models + +| Model | Authentication Authority | Use Case | +|-------|------------------------|----------| +| Federated (AD FS) | On-premises AD FS | Regulatory requirement to keep auth on-prem | +| Managed (PHS) | Azure AD with password hash sync | Simplest cloud auth, AD FS not needed | +| Managed (PTA) | On-premises via pass-through agent | Cloud auth validated against on-prem AD | +| Third-Party Federation | External IdP (Okta, Ping) | Multi-IdP environment | + +### SAML Federation Architecture + +``` +User β†’ Cloud App (SP) + β”‚ + └── Redirect to Azure AD + β”‚ + β”œβ”€β”€ Azure AD checks federated domain + β”‚ + └── Redirect to on-premises AD FS + β”‚ + β”œβ”€β”€ AD FS authenticates against Active Directory + β”‚ + β”œβ”€β”€ AD FS issues SAML token + β”‚ + └── Token posted back to Azure AD + β”‚ + β”œβ”€β”€ Azure AD validates federation trust + β”‚ + β”œβ”€β”€ Azure AD issues its own token + β”‚ + └── User receives access token for cloud app +``` + +### Federation Trust Components + +| Component | Description | +|-----------|-------------| +| Token-Signing Certificate | X.509 certificate used by IdP to sign SAML assertions | +| Federation Metadata | XML document describing IdP endpoints and capabilities | +| Relying Party Trust | Configuration in AD FS for each SP (Azure AD) | +| Claims Rules | Transform AD attributes into SAML claims | +| Issuer URI | Unique identifier for the IdP (entity ID) | + +## Workflow + +### Step 1: Prepare AD FS Infrastructure + +```powershell +# Install AD FS role +Install-WindowsFeature ADFS-Federation -IncludeManagementTools + +# Configure AD FS farm +Install-AdfsFarm ` + -CertificateThumbprint $certThumbprint ` + -FederationServiceDisplayName "Corp Federation Service" ` + -FederationServiceName "fs.corp.example.com" ` + -ServiceAccountCredential $gmsaCredential + +# Verify AD FS is operational +Get-AdfsProperties | Select-Object HostName, Identifier, FederationPassiveAddress +``` + +### Step 2: Configure Azure AD Federated Domain + +```powershell +# Install Microsoft Graph PowerShell module +Install-Module Microsoft.Graph -Scope CurrentUser + +# Connect to Microsoft Graph +Connect-MgGraph -Scopes "Domain.ReadWrite.All" + +# Convert managed domain to federated +# Using AD FS federation metadata URL +$domainId = "corp.example.com" +$federationConfig = @{ + issuerUri = "http://fs.corp.example.com/adfs/services/trust" + metadataExchangeUri = "https://fs.corp.example.com/adfs/services/trust/mex" + passiveSignInUri = "https://fs.corp.example.com/adfs/ls/" + signOutUri = "https://fs.corp.example.com/adfs/ls/?wa=wsignout1.0" + signingCertificate = $base64Cert + preferredAuthenticationProtocol = "saml" +} + +# Apply federation settings to domain +New-MgDomainFederationConfiguration -DomainId $domainId -BodyParameter $federationConfig +``` + +### Step 3: Configure AD FS Claims Rules + +```powershell +# Add Relying Party Trust for Azure AD +Add-AdfsRelyingPartyTrust ` + -Name "Microsoft Office 365 Identity Platform" ` + -MetadataUrl "https://nexus.microsoftonline-p.com/federationmetadata/2007-06/federationmetadata.xml" + +# Configure claim rules +$rules = @" +@RuleTemplate = "LdapClaims" +@RuleName = "Extract AD Attributes" +c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", + Issuer == "AD AUTHORITY"] +=> issue(store = "Active Directory", + types = ("http://schemas.xmlsoap.org/claims/UPN", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"), + query = ";userPrincipalName,mail,givenName,sn;{0}", + param = c.Value); + +@RuleTemplate = "PassThroughClaims" +@RuleName = "Pass Through UPN as NameID" +c:[Type == "http://schemas.xmlsoap.org/claims/UPN"] +=> issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", + Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, + Value = c.Value, + ValueType = c.ValueType, + Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] + = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); +"@ + +Set-AdfsRelyingPartyTrust ` + -TargetName "Microsoft Office 365 Identity Platform" ` + -IssuanceTransformRules $rules +``` + +### Step 4: Configure Third-Party SaaS Federation + +For each SaaS application that supports SAML SSO via Azure AD: + +1. Navigate to Microsoft Entra Admin Center > Enterprise Applications +2. Add the application from the gallery (or create custom SAML) +3. Configure Single Sign-On > SAML: + - Identifier (Entity ID): Application's entity ID + - Reply URL (ACS): Application's assertion consumer service URL + - Sign-on URL: Application's login URL +4. Map user attributes/claims: + - NameID: user.userprincipalname (email format) + - Additional claims as required by the application +5. Download the Federation Metadata XML or certificate +6. Configure the SaaS app with Azure AD's federation details + +### Step 5: Certificate Lifecycle Management + +AD FS token-signing certificates expire and must be renewed: + +```powershell +# Check current certificate expiration +Get-AdfsCertificate -CertificateType Token-Signing | Select-Object Thumbprint, NotAfter + +# AD FS supports auto-rollover (enabled by default) +Get-AdfsProperties | Select-Object AutoCertificateRollover + +# If manual rotation is needed: +# 1. Add new certificate as secondary +Set-AdfsCertificate -CertificateType Token-Signing -Thumbprint $newThumbprint -IsPrimary $false +# 2. Update Azure AD with new certificate +# 3. Promote to primary +Set-AdfsCertificate -CertificateType Token-Signing -Thumbprint $newThumbprint -IsPrimary $true +# 4. Remove old certificate +Remove-AdfsCertificate -CertificateType Token-Signing -Thumbprint $oldThumbprint +``` + +## Validation Checklist + +- [ ] AD FS farm operational with valid TLS and token-signing certificates +- [ ] Azure AD domain configured as federated with correct metadata +- [ ] Claims rules properly transform AD attributes to SAML assertions +- [ ] Test user can authenticate through federation flow end-to-end +- [ ] MFA enforced at AD FS or Azure AD conditional access level +- [ ] Certificate auto-rollover enabled or manual rotation scheduled +- [ ] Federation metadata endpoint publicly accessible +- [ ] Smart lockout configured to prevent brute force +- [ ] Extranet lockout policies configured on AD FS +- [ ] Monitoring configured for AD FS health and certificate expiry +- [ ] Disaster recovery: managed authentication fallback documented + +## References + +- [Microsoft Entra Federation Documentation](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-fed) +- [AD FS Design Guide](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/design/ad-fs-design-guide) +- [Configure AD FS for Azure AD Federation](https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-fed-management) +- [SAML 2.0 Authentication - OASIS](https://docs.oasis-open.org/security/saml/v2.0/) diff --git a/ai/knowledge/skills/building-identity-governance-lifecycle-process/SKILL.md b/ai/knowledge/skills/building-identity-governance-lifecycle-process/SKILL.md new file mode 100644 index 0000000..9567cb7 --- /dev/null +++ b/ai/knowledge/skills/building-identity-governance-lifecycle-process/SKILL.md @@ -0,0 +1,691 @@ +--- +name: building-identity-governance-lifecycle-process +description: 'Builds comprehensive identity governance and lifecycle management processes including joiner-mover-leaver automation, + role mining, access request workflows, periodic recertification, and orphaned account remediation using IGA platforms. Activates + for requests involving identity lifecycle management, JML processes, role-based access provisioning, or identity governance + program design. + + ' +domain: cybersecurity +subdomain: identity-access-management +tags: +- identity-governance +- lifecycle-management +- JML +- access-provisioning +- RBAC +- IGA +version: '1.0' +author: mahipal +license: Apache-2.0 +nist_ai_rmf: +- GOVERN-1.1 +- GOVERN-1.7 +- MAP-1.1 +nist_csf: +- PR.AA-01 +- PR.AA-02 +- PR.AA-05 +- PR.AA-06 +--- + +# Building Identity Governance Lifecycle Process + +## When to Use + +- Organization lacks automated joiner-mover-leaver (JML) processes for identity management +- Access provisioning is manual and takes days, creating productivity loss and security gaps +- Former employees retain access to systems after termination (orphaned accounts) +- Role explosion has created thousands of roles with unclear ownership and overlapping entitlements +- Compliance requirements mandate documented identity lifecycle processes (SOX, HIPAA, GDPR) +- No centralized visibility into who has access to what across the enterprise + +**Do not use** for single-application user management; identity governance addresses cross-system lifecycle management requiring correlation of authoritative HR sources with downstream application provisioning. + +## Prerequisites + +- Authoritative HR system (Workday, SAP SuccessFactors, BambooHR) as identity source of truth +- IGA platform (SailPoint, Saviynt, One Identity) or Microsoft Entra ID Governance +- Active Directory and/or Azure AD as primary directory services +- Application connectors for target systems requiring automated provisioning +- Defined organizational role structure and reporting hierarchy +- Stakeholder buy-in from HR, IT, security, and business unit managers + +## Workflow + +### Step 1: Define Identity Lifecycle States and Transitions + +Map the identity lifecycle from hire to termination: + +```python +""" +Identity Lifecycle State Machine +Defines all identity states and valid transitions with automated actions. +""" + +IDENTITY_LIFECYCLE = { + "states": { + "PRE_HIRE": { + "description": "Identity created from HR feed before start date", + "automated_actions": [ + "Create identity record in IGA platform", + "Generate unique employee ID", + "Create mailbox reservation", + "Assign birthright roles based on job code", + "Initiate background check workflow" + ], + "valid_transitions": ["ACTIVE", "CANCELLED"] + }, + "ACTIVE": { + "description": "Employee has started, full access provisioned", + "automated_actions": [ + "Create Active Directory account", + "Create email mailbox", + "Provision birthright application access", + "Assign department-specific roles", + "Add to distribution groups", + "Issue MFA token/security key", + "Create VPN account if remote worker" + ], + "valid_transitions": ["ROLE_CHANGE", "LEAVE_OF_ABSENCE", "TERMINATED"] + }, + "ROLE_CHANGE": { + "description": "Employee transferred, promoted, or changed departments", + "automated_actions": [ + "Recalculate role assignments based on new job code", + "Remove access from previous department applications", + "Provision access for new department applications", + "Update group memberships", + "Transfer manager in directory", + "Trigger access review for retained entitlements", + "Notify new manager of inherited access" + ], + "valid_transitions": ["ACTIVE", "LEAVE_OF_ABSENCE", "TERMINATED"] + }, + "LEAVE_OF_ABSENCE": { + "description": "Employee on extended leave (medical, parental, sabbatical)", + "automated_actions": [ + "Disable interactive login (preserve account)", + "Suspend VPN access", + "Set out-of-office auto-reply", + "Delegate mailbox to manager", + "Preserve all role assignments for return", + "Set reactivation date from HR feed" + ], + "valid_transitions": ["ACTIVE", "TERMINATED"] + }, + "TERMINATED": { + "description": "Employee has left the organization", + "automated_actions": [ + "Disable AD account immediately", + "Revoke all application access", + "Revoke VPN and remote access", + "Convert mailbox to shared (manager access for 90 days)", + "Transfer OneDrive files to manager", + "Remove from all security and distribution groups", + "Revoke OAuth tokens and API keys", + "Wipe corporate data from mobile devices", + "Archive identity record", + "Schedule account deletion after retention period" + ], + "valid_transitions": ["REHIRE", "DELETED"] + }, + "REHIRE": { + "description": "Previously terminated employee returning", + "automated_actions": [ + "Reactivate existing identity record", + "Reset credentials and require MFA re-enrollment", + "Provision based on new job code (not previous access)", + "Flag for enhanced access review in first 30 days" + ], + "valid_transitions": ["ACTIVE"] + }, + "DELETED": { + "description": "Account permanently removed after retention period", + "automated_actions": [ + "Delete AD account", + "Delete email mailbox archive", + "Remove identity record from IGA", + "Generate deletion audit log" + ], + "valid_transitions": [] + } + }, + "retention_periods": { + "terminated_to_deleted": "90 days (default)", + "mailbox_retention": "90 days as shared mailbox", + "onedrive_retention": "30 days manager access, then archived", + "audit_log_retention": "7 years for compliance" + } +} +``` + +### Step 2: Implement Authoritative Source Integration + +Connect HR system as the single source of truth for identity data: + +```python +""" +HR Source Integration - Workday to IGA Platform Connector +Polls Workday for employee lifecycle events and triggers provisioning. +""" +import requests +from datetime import datetime, timedelta +import logging + +class WorkdayIdentityConnector: + def __init__(self, config): + self.base_url = config["workday_api_url"] + self.tenant = config["tenant"] + self.client_id = config["client_id"] + self.client_secret = config["client_secret"] + self.session = requests.Session() + self.logger = logging.getLogger("workday_connector") + + def get_access_token(self): + """Authenticate to Workday REST API.""" + token_url = f"{self.base_url}/ccx/oauth2/{self.tenant}/token" + response = self.session.post(token_url, data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret + }) + response.raise_for_status() + return response.json()["access_token"] + + def fetch_worker_changes(self, since_datetime): + """Fetch all worker lifecycle events since the last sync.""" + headers = {"Authorization": f"Bearer {self.get_access_token()}"} + params = { + "Updated_From": since_datetime.isoformat(), + "Updated_Through": datetime.utcnow().isoformat(), + "Count": 100 + } + + workers = [] + url = f"{self.base_url}/ccx/api/v1/{self.tenant}/workers" + + while url: + response = self.session.get(url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + workers.extend(data.get("data", [])) + url = data.get("next", None) + params = {} + + return workers + + def map_lifecycle_event(self, worker): + """Map Workday worker data to identity lifecycle event.""" + worker_data = worker.get("workerData", {}) + employment = worker_data.get("employmentData", {}) + personal = worker_data.get("personalData", {}) + + event = { + "employee_id": worker.get("id"), + "first_name": personal.get("legalName", {}).get("firstName"), + "last_name": personal.get("legalName", {}).get("lastName"), + "email": worker_data.get("emailAddress"), + "job_code": employment.get("jobProfile", {}).get("id"), + "job_title": employment.get("jobProfile", {}).get("name"), + "department": employment.get("organization", {}).get("name"), + "department_code": employment.get("organization", {}).get("id"), + "manager_id": employment.get("managerId"), + "location": employment.get("location", {}).get("name"), + "cost_center": employment.get("costCenter", {}).get("id"), + "hire_date": employment.get("hireDate"), + "termination_date": employment.get("terminationDate"), + "status": employment.get("status"), + "worker_type": employment.get("workerType"), + } + + # Determine lifecycle transition + if event["status"] == "Active" and event["hire_date"]: + hire_date = datetime.fromisoformat(event["hire_date"]) + if hire_date > datetime.utcnow(): + event["lifecycle_event"] = "PRE_HIRE" + else: + event["lifecycle_event"] = "JOINER" + elif event["status"] == "Active": + event["lifecycle_event"] = "MOVER" # Department or role change + elif event["status"] == "Terminated": + event["lifecycle_event"] = "LEAVER" + elif event["status"] == "On Leave": + event["lifecycle_event"] = "LEAVE_OF_ABSENCE" + + return event + + def process_lifecycle_events(self, since_datetime): + """Main processing loop for identity lifecycle events.""" + workers = self.fetch_worker_changes(since_datetime) + events = [] + + for worker in workers: + event = self.map_lifecycle_event(worker) + events.append(event) + self.logger.info( + f"Lifecycle event: {event['lifecycle_event']} for " + f"{event['first_name']} {event['last_name']} " + f"(EmpID: {event['employee_id']})" + ) + + return events +``` + +### Step 3: Implement Role Mining and Birthright Access + +Define roles based on job functions for automated provisioning: + +```python +""" +Role Mining Engine +Analyzes existing access patterns to derive role definitions +for birthright (automatic) provisioning. +""" +import pandas as pd +from collections import Counter +from itertools import combinations + +class RoleMiningEngine: + def __init__(self, access_data): + """ + access_data: DataFrame with columns + [employee_id, job_code, department, application, entitlement] + """ + self.access_data = access_data + + def mine_birthright_roles(self, min_assignment_pct=0.8): + """ + Identify entitlements that should be automatically assigned + based on job code. If 80%+ of users with same job code + have an entitlement, it becomes birthright access. + """ + birthright_roles = {} + + for job_code, group in self.access_data.groupby("job_code"): + total_users = group["employee_id"].nunique() + entitlement_counts = group.groupby( + ["application", "entitlement"] + )["employee_id"].nunique() + + birthright_entitlements = [] + for (app, ent), count in entitlement_counts.items(): + pct = count / total_users + if pct >= min_assignment_pct: + birthright_entitlements.append({ + "application": app, + "entitlement": ent, + "assignment_percentage": round(pct * 100, 1), + "user_count": count + }) + + if birthright_entitlements: + birthright_roles[job_code] = { + "job_code": job_code, + "total_users": total_users, + "birthright_entitlements": birthright_entitlements + } + + return birthright_roles + + def detect_role_explosion(self): + """Identify roles with excessive overlap indicating need for consolidation.""" + roles = self.access_data.groupby("job_code").apply( + lambda x: set(zip(x["application"], x["entitlement"])) + ) + + overlap_report = [] + for (role1, ents1), (role2, ents2) in combinations(roles.items(), 2): + if len(ents1) == 0 or len(ents2) == 0: + continue + overlap = len(ents1 & ents2) + max_size = max(len(ents1), len(ents2)) + overlap_pct = overlap / max_size * 100 + + if overlap_pct > 70: + overlap_report.append({ + "role_1": role1, + "role_2": role2, + "role_1_entitlements": len(ents1), + "role_2_entitlements": len(ents2), + "overlapping_entitlements": overlap, + "overlap_percentage": round(overlap_pct, 1), + "recommendation": "CONSOLIDATE" if overlap_pct > 90 else "REVIEW" + }) + + return sorted(overlap_report, key=lambda x: x["overlap_percentage"], reverse=True) + + def find_orphaned_access(self): + """ + Find entitlements that no longer align with any role definition. + These are exceptions that accumulated over time. + """ + # Get birthright definitions + birthright = self.mine_birthright_roles(min_assignment_pct=0.5) + + orphaned = [] + for _, row in self.access_data.iterrows(): + job_birthright = birthright.get(row["job_code"], {}) + expected_ents = set() + for ent in job_birthright.get("birthright_entitlements", []): + expected_ents.add((ent["application"], ent["entitlement"])) + + current_ent = (row["application"], row["entitlement"]) + if current_ent not in expected_ents: + orphaned.append({ + "employee_id": row["employee_id"], + "job_code": row["job_code"], + "application": row["application"], + "entitlement": row["entitlement"], + "recommendation": "Review for revocation" + }) + + return pd.DataFrame(orphaned) +``` + +### Step 4: Build Access Request and Approval Workflow + +Implement self-service access request with risk-based approvals: + +```python +""" +Access Request Workflow Engine +Handles self-service access requests with multi-level approvals +based on risk classification of requested entitlements. +""" + +ACCESS_REQUEST_WORKFLOW = { + "risk_levels": { + "LOW": { + "description": "Standard business applications", + "examples": ["Email distribution groups", "SharePoint team sites", "Standard SaaS apps"], + "approval_chain": ["manager"], + "sla_hours": 4, + "auto_approve_if_birthright": True + }, + "MEDIUM": { + "description": "Sensitive data access or elevated permissions", + "examples": ["CRM admin", "Financial reporting", "HR systems"], + "approval_chain": ["manager", "application_owner"], + "sla_hours": 24, + "auto_approve_if_birthright": False + }, + "HIGH": { + "description": "Privileged access or regulated data", + "examples": ["Database admin", "Cloud admin", "PAM vault access"], + "approval_chain": ["manager", "application_owner", "security_team"], + "sla_hours": 48, + "auto_approve_if_birthright": False, + "require_justification": True, + "require_time_limit": True + }, + "CRITICAL": { + "description": "Domain admin, root access, or production data modification", + "examples": ["Domain Admin", "AWS root", "Production DB write"], + "approval_chain": ["manager", "application_owner", "security_team", "ciso"], + "sla_hours": 72, + "auto_approve_if_birthright": False, + "require_justification": True, + "require_time_limit": True, + "require_sod_check": True, + "max_duration_days": 90 + } + } +} + +class AccessRequestEngine: + def __init__(self, iga_client, risk_catalog): + self.iga = iga_client + self.risk_catalog = risk_catalog + + def submit_request(self, requester_id, entitlement_id, justification, duration_days=None): + """Submit an access request with automatic risk classification.""" + # Classify risk level of requested entitlement + risk_level = self.risk_catalog.get_risk_level(entitlement_id) + workflow = ACCESS_REQUEST_WORKFLOW["risk_levels"][risk_level] + + # Check if entitlement is birthright for requester's role + requester = self.iga.get_identity(requester_id) + is_birthright = self.iga.is_birthright_for_role( + entitlement_id, requester["job_code"] + ) + + if is_birthright and workflow.get("auto_approve_if_birthright"): + return self._auto_approve(requester_id, entitlement_id, "Birthright access") + + # Run SOD check if required + if workflow.get("require_sod_check"): + sod_violations = self.iga.check_sod(requester_id, entitlement_id) + if sod_violations: + return { + "status": "SOD_VIOLATION", + "violations": sod_violations, + "action": "Request requires compensating control approval" + } + + # Create approval chain + request = { + "requester": requester_id, + "entitlement": entitlement_id, + "risk_level": risk_level, + "justification": justification, + "duration_days": duration_days or workflow.get("max_duration_days"), + "approval_chain": self._build_approval_chain( + requester, workflow["approval_chain"] + ), + "sla_deadline": workflow["sla_hours"], + "status": "PENDING_APPROVAL" + } + + return self.iga.create_request(request) + + def _build_approval_chain(self, requester, approver_types): + """Resolve approval chain to actual approver identities.""" + chain = [] + for approver_type in approver_types: + if approver_type == "manager": + chain.append({ + "type": "manager", + "identity": requester["manager_id"], + "fallback": requester.get("skip_manager_id") + }) + elif approver_type == "application_owner": + chain.append({ + "type": "application_owner", + "identity": "resolved_at_runtime", + "fallback": "it-governance-team" + }) + elif approver_type == "security_team": + chain.append({ + "type": "group", + "identity": "security-governance-team", + "required_approvals": 1 + }) + elif approver_type == "ciso": + chain.append({ + "type": "role", + "identity": "CISO", + "fallback": "deputy-ciso" + }) + return chain +``` + +### Step 5: Implement Orphaned Account Detection and Remediation + +Identify and remediate accounts without active identity associations: + +```python +""" +Orphaned Account Detection +Identifies accounts in target systems that have no corresponding +active identity in the authoritative HR source. +""" + +class OrphanedAccountDetector: + def __init__(self, hr_connector, app_connectors): + self.hr = hr_connector + self.apps = app_connectors + + def detect_orphaned_accounts(self): + """Compare application accounts against HR active employees.""" + active_employees = set(self.hr.get_active_employee_ids()) + orphaned_accounts = [] + + for app_name, connector in self.apps.items(): + app_accounts = connector.get_all_accounts() + + for account in app_accounts: + correlated_id = account.get("employee_id") or account.get("correlation_id") + + if correlated_id and correlated_id not in active_employees: + # Check if recently terminated (within grace period) + termination_info = self.hr.get_termination_info(correlated_id) + + orphaned_accounts.append({ + "application": app_name, + "account_name": account["username"], + "correlated_employee_id": correlated_id, + "account_status": account.get("status", "unknown"), + "last_login": account.get("last_login"), + "termination_date": termination_info.get("date") if termination_info else None, + "days_since_termination": ( + (datetime.utcnow() - termination_info["date"]).days + if termination_info and termination_info.get("date") else None + ), + "risk_level": self._assess_orphan_risk(account, termination_info) + }) + + elif not correlated_id: + # Uncorrelated account - no link to any employee + orphaned_accounts.append({ + "application": app_name, + "account_name": account["username"], + "correlated_employee_id": None, + "account_status": account.get("status", "unknown"), + "last_login": account.get("last_login"), + "risk_level": "HIGH", + "reason": "Uncorrelated - no employee association" + }) + + return orphaned_accounts + + def _assess_orphan_risk(self, account, termination_info): + """Assess risk level of orphaned account.""" + if account.get("is_privileged"): + return "CRITICAL" + if termination_info and termination_info.get("involuntary"): + return "HIGH" + if account.get("status") == "active": + return "HIGH" + return "MEDIUM" + + def generate_remediation_plan(self, orphaned_accounts): + """Create remediation actions for orphaned accounts.""" + plan = [] + for account in orphaned_accounts: + if account["risk_level"] == "CRITICAL": + action = "DISABLE_IMMEDIATELY" + sla = "4 hours" + elif account["risk_level"] == "HIGH": + action = "DISABLE_WITHIN_24H" + sla = "24 hours" + else: + action = "REVIEW_AND_DISABLE" + sla = "7 days" + + plan.append({ + **account, + "remediation_action": action, + "sla": sla, + "assigned_to": "identity-governance-team" + }) + + return sorted(plan, key=lambda x: ["CRITICAL", "HIGH", "MEDIUM", "LOW"].index(x["risk_level"])) +``` + +## Key Concepts + +| Term | Definition | +|------|------------| +| **Joiner-Mover-Leaver (JML)** | Core identity lifecycle transitions covering employee onboarding (joiner), role/department changes (mover), and offboarding (leaver) | +| **Birthright Access** | Baseline entitlements automatically provisioned based on job code, department, or location without requiring an access request | +| **Role Mining** | Analysis of existing access patterns to derive role definitions by identifying common entitlement groupings across similar job functions | +| **Orphaned Account** | Application account that no longer has a corresponding active identity in the authoritative HR source, representing a security risk | +| **Authoritative Source** | System of record (typically HR) that serves as the single source of truth for identity attributes and employment status | +| **Access Request Workflow** | Self-service process enabling users to request additional entitlements with risk-based approval routing | + +## Tools & Systems + +- **SailPoint IdentityIQ/IdentityNow**: Enterprise IGA platform for lifecycle management, access certifications, and automated provisioning +- **Saviynt Enterprise Identity Cloud**: Cloud-native IGA with identity warehouse, access governance, and application access management +- **Microsoft Entra ID Governance**: Identity governance capabilities including lifecycle workflows, access reviews, and entitlement management +- **One Identity Manager**: IGA solution with business role management, attestation, and IT shop for access requests + +## Common Scenarios + +### Scenario: Building JML Process for 10,000-Employee Organization + +**Context**: Rapidly growing company has no automated identity lifecycle. IT manually creates accounts, taking 3-5 days for new hires. Terminated employees retain access for weeks. Audit found 2,300 orphaned accounts across 45 applications. + +**Approach**: +1. Integrate Workday as authoritative source with daily delta sync to IGA platform +2. Mine existing access patterns to define birthright roles for the top 20 job codes (covering 80% of employees) +3. Implement pre-hire provisioning triggered 7 days before start date for AD, email, and birthright apps +4. Build termination workflow that disables all access within 1 hour of HR status change +5. Create mover workflow that recalculates roles when job code or department changes +6. Deploy self-service access request portal with risk-based approval chains +7. Run orphaned account detection to identify and remediate the 2,300 existing orphans +8. Schedule quarterly access certifications to prevent access accumulation + +**Pitfalls**: +- Not defining a single authoritative source leads to conflicting identity data from multiple HR systems +- Mining roles without business validation creates technical roles that do not align with organizational structure +- Automating termination without grace period for knowledge transfer frustrates business managers +- Not handling contractor and vendor identities that exist outside the HR system + +## Output Format + +``` +IDENTITY GOVERNANCE LIFECYCLE REPORT +======================================= +Authoritative Source: Workday +IGA Platform: SailPoint IdentityIQ +Total Identities: 10,247 +Active Employees: 9,834 +Contractors: 413 + +LIFECYCLE AUTOMATION +Joiner (Pre-Hire) SLA: Target: 0 days | Actual: 0.2 days avg +Mover Processing SLA: Target: 1 day | Actual: 0.8 days avg +Leaver Disablement SLA: Target: 1 hour | Actual: 0.5 hours avg + +PROVISIONING METRICS (Last 30 Days) +New Hires Provisioned: 187 + Auto-Provisioned: 174 (93.0%) + Manual Intervention: 13 (7.0%) +Role Changes Processed: 89 +Terminations Processed: 43 + Within 1-Hour SLA: 41 (95.3%) + +ROLE GOVERNANCE +Defined Roles: 127 +Birthright Roles: 48 +Average Entitlements/Role: 12.3 +Role Overlap > 70%: 8 pairs (consolidation recommended) + +ORPHANED ACCOUNTS +Detected: 23 + Critical: 2 (privileged accounts) + High: 8 + Medium: 13 +Remediated (30 days): 19 +Outstanding: 4 + +ACCESS REQUESTS +Submitted: 342 +Auto-Approved (Birthright):87 (25.4%) +Approved: 231 (67.5%) +Denied: 24 (7.0%) +Average Approval Time: 6.2 hours +SOD Violations Flagged: 12 +``` diff --git a/ai/loader.py b/ai/loader.py new file mode 100644 index 0000000..f0047fa --- /dev/null +++ b/ai/loader.py @@ -0,0 +1,196 @@ +""" +Document loader for OpenShield rules and compliance frameworks. +Loads scanner rules, CIS, NIST, ISO 27001 and SOC2 controls +into structured documents ready for chunking and embedding. +""" + +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +# Root of the OpenShield project +PROJECT_ROOT = Path(__file__).parent.parent + +RULES_DIR = PROJECT_ROOT / "scanner" / "rules" +COMPLIANCE_DIR = PROJECT_ROOT / "compliance" / "frameworks" + +COMPLIANCE_FILES = { + "CIS Azure Benchmark": COMPLIANCE_DIR / "cis_azure_benchmark.json", + "NIST CSF": COMPLIANCE_DIR / "nist_csf.json", + "ISO 27001": COMPLIANCE_DIR / "iso27001.json", + "SOC2": COMPLIANCE_DIR / "soc2.json", +} + + +def load_rule_documents() -> List[Dict[str, Any]]: + """ + Load all OpenShield scanner rules as documents. + + Each rule file is parsed for its constants and returned + as a structured document with metadata. + + Returns: + List of document dicts with content and metadata. + """ + documents = [] + + for rule_file in sorted(RULES_DIR.glob("az_*.py")): + try: + content = rule_file.read_text(encoding="utf-8") + + # Extract key fields using simple string parsing + rule_id = _extract_string(content, "RULE_ID") + rule_name = _extract_string(content, "RULE_NAME") + severity = _extract_string(content, "SEVERITY") + category = _extract_string(content, "CATEGORY") + description = _extract_multiline(content, "DESCRIPTION") + remediation = _extract_multiline(content, "REMEDIATION") + + if not rule_id: + logger.warning("Skipping %s β€” no RULE_ID found", rule_file.name) + continue + + # Build rich text content for embedding + text = ( + f"Rule ID: {rule_id}\n" + f"Rule Name: {rule_name}\n" + f"Severity: {severity}\n" + f"Category: {category}\n" + f"Description: {description}\n" + f"Remediation: {remediation}\n" + ) + + documents.append({ + "id": f"rule_{rule_id.lower().replace('-', '_')}", + "content": text, + "metadata": { + "source": "openShield_rule", + "rule_id": rule_id, + "rule_name": rule_name, + "severity": severity, + "category": category, + "file": rule_file.name, + }, + }) + + logger.debug("Loaded rule: %s", rule_id) + + except Exception as exc: + logger.error("Failed to load rule %s: %s", rule_file.name, exc) + + logger.info("Loaded %d rule documents", len(documents)) + return documents + + +def load_compliance_documents() -> List[Dict[str, Any]]: + """ + Load all compliance framework controls as documents. + + Returns: + List of document dicts with content and metadata. + """ + documents = [] + + for framework_name, filepath in COMPLIANCE_FILES.items(): + if not filepath.exists(): + logger.warning("Compliance file not found: %s", filepath) + continue + + try: + with open(filepath, encoding="utf-8") as f: + data = json.load(f) + + framework_version = data.get("version", "") + controls = data.get("controls", {}) + + for rule_id, control in controls.items(): + control_id = control.get("control_id", "") + control_name = control.get("control_name", "") + description = control.get("description", "") + + text = ( + f"Framework: {framework_name}\n" + f"Version: {framework_version}\n" + f"Rule ID: {rule_id}\n" + f"Control ID: {control_id}\n" + f"Control Name: {control_name}\n" + f"Description: {description}\n" + ) + + documents.append({ + "id": f"compliance_{framework_name.lower().replace(' ', '_')}_{rule_id.lower().replace('-', '_')}", + "content": text, + "metadata": { + "source": "compliance_framework", + "framework": framework_name, + "framework_version": framework_version, + "rule_id": rule_id, + "control_id": control_id, + "control_name": control_name, + }, + }) + + logger.info( + "Loaded %d controls from %s", len(controls), framework_name + ) + + except Exception as exc: + logger.error( + "Failed to load compliance file %s: %s", filepath, exc + ) + + logger.info("Loaded %d compliance documents total", len(documents)) + return documents + + +def load_all_documents() -> List[Dict[str, Any]]: + """ + Load all OpenShield documents β€” rules and compliance frameworks. + + Returns: + Combined list of all document dicts. + """ + rules = load_rule_documents() + compliance = load_compliance_documents() + all_docs = rules + compliance + logger.info("Total documents loaded: %d", len(all_docs)) + return all_docs + + +# ------------------------------------------------------------------ # +# Private helpers # +# ------------------------------------------------------------------ # + +def _extract_string(content: str, key: str) -> str: + """Extract a simple string constant from Python source.""" + for line in content.splitlines(): + line = line.strip() + if line.startswith(f"{key} ="): + parts = line.split("=", 1) + if len(parts) == 2: + value = parts[1].strip().strip('"').strip("'") + return value + return "" + + +def _extract_multiline(content: str, key: str) -> str: + """Extract a multi-line string constant from Python source.""" + lines = content.splitlines() + result = [] + inside = False + + for line in lines: + stripped = line.strip() + if stripped.startswith(f"{key} = ("): + inside = True + continue + if inside: + if stripped == ")": + break + result.append(stripped.strip('"').strip("'")) + + return " ".join(result).strip() diff --git a/ai/retriever.py b/ai/retriever.py new file mode 100644 index 0000000..5e97363 --- /dev/null +++ b/ai/retriever.py @@ -0,0 +1,52 @@ +"""Retrieve relevant OpenShield knowledge from the vector store for RAG.""" + +import logging +from pathlib import Path + +try: + import chromadb +except ImportError: + chromadb = None + +logger = logging.getLogger(__name__) + +REPO_ROOT = Path(__file__).resolve().parent.parent +VECTORSTORE_DIR = REPO_ROOT / "ai" / "vectorstore" +COLLECTION_NAME = "openshield" + + +class VectorStoreNotBuilt(RuntimeError): + """Raised when the vector store is missing or chromadb is unavailable.""" + + +def _get_collection(): + if chromadb is None: + raise VectorStoreNotBuilt( + "chromadb is not installed. Install it with 'pip install chromadb'." + ) + if not VECTORSTORE_DIR.exists(): + raise VectorStoreNotBuilt( + "Vector store not found. Run 'python ai/embed.py' first." + ) + client = chromadb.PersistentClient(path=str(VECTORSTORE_DIR)) + try: + return client.get_collection(COLLECTION_NAME) + except Exception as exc: + raise VectorStoreNotBuilt( + "Vector store collection missing. Run 'python ai/embed.py' first." + ) from exc + + +def retrieve(query, n_results=5): + """Return the most relevant knowledge chunks for a query. + + Each result is a dict with 'text' and 'source'. + """ + collection = _get_collection() + results = collection.query(query_texts=[query], n_results=n_results) + documents = results.get("documents", [[]])[0] + metadatas = results.get("metadatas", [[]])[0] + chunks = [] + for text, meta in zip(documents, metadatas): + chunks.append({"text": text, "source": (meta or {}).get("source", "")}) + return chunks diff --git a/api/app.py b/api/app.py index 5969090..5b2f3ec 100644 --- a/api/app.py +++ b/api/app.py @@ -9,6 +9,7 @@ from flask_cors import CORS from api.models.finding import DatabaseManager +from api.routes.ai import ai_bp load_dotenv() @@ -21,6 +22,64 @@ # Paths that do not require a JWT token _PUBLIC_PATHS = {"/health", "/"} +_INSECURE_JWT_DEFAULT = "change-me-in-production" +_MIN_JWT_SECRET_LENGTH = 32 +_GENERATE_CMD = "python -c \"import secrets; print(secrets.token_urlsafe(32))\"" + + +def _is_production() -> bool: + return ( + os.environ.get("OPENSHIELD_ENV", "").lower() == "production" + or os.environ.get("RENDER", "").lower() == "true" + ) + + +def _is_development() -> bool: + return ( + os.environ.get("OPENSHIELD_ENV", "").lower() == "development" + or os.environ.get("FLASK_DEBUG", "").lower() == "true" + ) + + +def _resolve_jwt_secret() -> str: + """Return the JWT signing secret, enforcing production safety rules. + + Production (OPENSHIELD_ENV=production or RENDER=true): raises RuntimeError + if the secret is missing, is the known insecure default, or is shorter than + 32 characters. All other environments allow the default with a loud warning. + """ + jwt_key = os.environ.get("JWT_SECRET", "") + if _is_production(): + if not jwt_key: + raise RuntimeError( + "FATAL: JWT_SECRET is not set. " + "Production deployments require a strong, unique JWT_SECRET. " + f"Generate one with: {_GENERATE_CMD}" + ) + if jwt_key == _INSECURE_JWT_DEFAULT: + raise RuntimeError( + "FATAL: JWT_SECRET is set to the insecure default value. " + "Production deployments must use a unique secret. " + f"Generate one with: {_GENERATE_CMD}" + ) + if len(jwt_key) < _MIN_JWT_SECRET_LENGTH: + raise RuntimeError( + f"FATAL: JWT_SECRET must be at least {_MIN_JWT_SECRET_LENGTH} characters. " + f"Generate one with: {_GENERATE_CMD}" + ) + return jwt_key + + if not jwt_key: + logger.warning( + "!!! SECURITY WARNING: JWT_SECRET NOT SET. " + "Using insecure default for local development only. " + "Set OPENSHIELD_ENV=production (or RENDER=true) to enforce a strong " + "secret and block startup when it is missing. !!!" + ) + return _INSECURE_JWT_DEFAULT + + return jwt_key + def create_app() -> Flask: """Create and configure the Flask application. @@ -37,14 +96,7 @@ def create_app() -> Flask: # ------------------------------------------------------------------ # # Configuration & Security # # ------------------------------------------------------------------ # - jwt_key = os.environ.get("JWT_SECRET") - if not jwt_key: - logger.warning( - "!!! SECURITY WARNING: JWT_SECRET NOT SET. USING INSECURE DEFAULT !!! " - "For production deployments, you MUST set a strong, unique JWT_SECRET." - ) - jwt_key = "change-me-in-production" - app.config["JWT_SECRET"] = jwt_key + app.config["JWT_SECRET"] = _resolve_jwt_secret() # ------------------------------------------------------------------ # # CORS # @@ -61,12 +113,17 @@ def create_app() -> Flask: # ------------------------------------------------------------------ # # Database Management # # ------------------------------------------------------------------ # + with app.app_context(): + db = DatabaseManager() + db.run_migrations() @app.teardown_appcontext def close_db(error=None): """Ensure the database connection is closed after the request.""" - db = g.pop("db", None) - if db is not None: + for key in ("db", "db_conn"): + db = g.pop(key, None) + if db is None: + continue try: if hasattr(db, "conn") and db.conn is not None: db.conn.close() @@ -162,7 +219,7 @@ def internal_error(exc): logger.error("Unhandled exception: %s", exc) return jsonify({"error": "Internal server error"}), 500 - logger.info("OpenShield API created β€” %d blueprints registered", len(app.blueprints)) + logger.info("OpenShield API created - %d blueprints registered", len(app.blueprints)) return app @@ -173,4 +230,4 @@ def internal_error(exc): host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=os.environ.get("FLASK_DEBUG", "false").lower() == "true", - ) \ No newline at end of file + ) diff --git a/api/models/finding.py b/api/models/finding.py index 6f03068..f344ef5 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -42,6 +42,9 @@ class Finding: scan_id: Optional[str] = None playbook: Optional[str] = None metadata: Dict[str, Any] = field(default_factory=dict) + cve_references: List[Dict[str, Any]] = field(default_factory=list) + cvss_score: Optional[float] = None + exploit_available: bool = False id: Optional[int] = None def to_dict(self) -> Dict[str, Any]: @@ -61,6 +64,9 @@ def to_dict(self) -> Dict[str, Any]: "scan_id": self.scan_id, "playbook": self.playbook, "metadata": self.metadata, + "cve_references": self.cve_references, + "cvss_score": self.cvss_score, + "exploit_available": self.exploit_available, } @@ -108,11 +114,19 @@ def close(self) -> None: # ------------------------------------------------------------------ # def init_db(self) -> None: - """Alias for create_tables to match startup script expectations.""" - self.create_tables() + """Alias for run_migrations. Called by startup.sh on every boot. + + Calling this is always safe β€” run_migrations() handles both fresh + databases and existing ones via IF NOT EXISTS guards throughout. + """ + self.run_migrations() def create_tables(self) -> None: - """Create the findings, scans, and rules tables if they do not exist.""" + """Create the findings, scans, and rules tables if they do not exist. + + Includes all columns β€” including CVE columns β€” so fresh databases + never need the ALTER TABLE path in run_migrations(). + """ conn = self._get_conn() with conn.cursor() as cur: cur.execute(""" @@ -140,6 +154,9 @@ def create_tables(self) -> None: playbook TEXT, frameworks JSONB, metadata JSONB, + cve_references JSONB DEFAULT '[]', + cvss_score FLOAT DEFAULT NULL, + exploit_available BOOLEAN DEFAULT FALSE, detected_at TIMESTAMPTZ NOT NULL ); """) @@ -154,6 +171,43 @@ def create_tables(self) -> None: conn.commit() logger.info("Database tables created / verified") + def run_migrations(self) -> None: + """Ensure the schema is fully current. Safe to call on every startup. + + Calls create_tables() first so the call order never matters β€” this + method is safe whether the database is brand new or has existing data. + + On a fresh database: + create_tables() creates all tables including CVE columns. + The ALTER TABLE below is a no-op (IF NOT EXISTS). + + On a pre-CVE database (existed before this feature was merged): + create_tables() verifies tables exist and skips creation. + The ALTER TABLE adds the three CVE columns. + + Concurrent startup safety: + Both CREATE TABLE IF NOT EXISTS and ALTER TABLE ADD COLUMN IF NOT + EXISTS are atomic at the PostgreSQL catalog level. Two Render + instances racing at boot will not error β€” the second call silently + no-ops on whichever statement the first already completed. + """ + self.create_tables() + + conn = self._get_conn() + try: + with conn.cursor() as cur: + cur.execute(""" + ALTER TABLE findings + ADD COLUMN IF NOT EXISTS cve_references JSONB DEFAULT '[]', + ADD COLUMN IF NOT EXISTS cvss_score FLOAT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS exploit_available BOOLEAN DEFAULT FALSE + """) + conn.commit() + logger.info("CVE migrations applied successfully") + except Exception as e: + logger.error("Failed to run CVE migrations: %s", e) + conn.rollback() + # ------------------------------------------------------------------ # # Write # # ------------------------------------------------------------------ # @@ -183,8 +237,9 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: (scan_id, rule_id, rule_name, severity, category, resource_id, resource_name, resource_type, description, remediation, playbook, - frameworks, metadata, detected_at) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + frameworks, metadata, cve_references, + cvss_score, exploit_available, detected_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( f.get("scan_id"), @@ -200,11 +255,18 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: f.get("playbook"), json.dumps(f.get("frameworks", {})), json.dumps(f.get("metadata", {})), + json.dumps(f.get("cve_references", [])), + f.get("cvss_score"), + f.get("exploit_available", False), f.get("detected_at"), ), ) conn.commit() - logger.info("Saved scan %s with %d findings", scan_result["scan_id"], scan_result["total_findings"]) + logger.info( + "Saved scan %s with %d findings", + scan_result["scan_id"], + scan_result["total_findings"], + ) # ------------------------------------------------------------------ # # Read # @@ -245,6 +307,37 @@ def get_finding_by_id(self, finding_id: int) -> Optional[Dict[str, Any]]: row = cur.fetchone() return dict(row) if row else None + def update_cve_fields(self, findings: List[Dict[str, Any]]) -> None: + """Persist CVE enrichment fields for existing findings. + + Updates are no-ops for findings without an id. + """ + if not findings: + return + + conn = self._get_conn() + with conn.cursor() as cur: + for f in findings: + finding_id = f.get("id") + if not finding_id: + continue + cur.execute( + """ + UPDATE findings + SET cve_references = %s, + cvss_score = %s, + exploit_available = %s + WHERE id = %s + """, + ( + json.dumps(f.get("cve_references", [])), + f.get("cvss_score"), + f.get("exploit_available", False), + finding_id, + ), + ) + conn.commit() + def get_scans(self) -> List[Dict[str, Any]]: """Return all scan records ordered by most recent first.""" conn = self._get_conn() @@ -257,7 +350,7 @@ def get_scans(self) -> List[Dict[str, Any]]: # ------------------------------------------------------------------ # def get_score(self) -> int: - """Return a 0–100 security posture score based on open findings. + """Return a 0-100 security posture score based on open findings. HIGH findings deduct 10 points each, MEDIUM 5, LOW 2. Score floors at 0. @@ -274,6 +367,38 @@ def get_score(self) -> int: ) return max(0, 100 - deduction) + def get_cve_summary(self) -> Dict[str, Any]: + """Return high-level summary of CVE findings for the dashboard.""" + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute(""" + SELECT + COUNT(*) as total_findings, + COUNT(CASE WHEN exploit_available = TRUE THEN 1 END) as exploit_count, + MAX(cvss_score) as max_cvss_score, + AVG(cvss_score) as avg_cvss_score, + COUNT(CASE WHEN cvss_score >= 9.0 THEN 1 END) as critical_cve_count + FROM findings + """) + row = cur.fetchone() + + if not row: + return { + "total_findings": 0, + "exploit_count": 0, + "max_cvss_score": None, + "avg_cvss_score": None, + "critical_cve_count": 0, + } + + return { + "total_findings": row[0], + "exploit_count": row[1], + "max_cvss_score": row[2], + "avg_cvss_score": round(row[3], 2) if row[3] is not None else None, + "critical_cve_count": row[4], + } + def get_compliance_score(self, framework: str) -> Dict[str, Any]: """Return pass/fail breakdown against a compliance framework. @@ -326,4 +451,4 @@ def get_compliance_score(self, framework: str) -> Dict[str, Any]: "failed": failed, "score_percent": score_pct, "controls": results, - } + } \ No newline at end of file diff --git a/api/routes/ai.py b/api/routes/ai.py index 5946952..737765a 100644 --- a/api/routes/ai.py +++ b/api/routes/ai.py @@ -1,13 +1,15 @@ -"""AI insights route: executive summary and prioritised remediation plan.""" +"""AI insights routes: executive summary, RAG-grounded analysis, and Q&A.""" +import json import logging from flask import Blueprint, jsonify, request from api.services.ai_provider import PROVIDERS as SUPPORTED_PROVIDERS from api.services.ai_provider import get_completion +from ai.retriever import retrieve, VectorStoreNotBuilt -ai_bp = Blueprint("ai", __name__, url_prefix="/api/ai") +ai_bp = Blueprint("ai", __name__) logger = logging.getLogger(__name__) _SEVERITY_RANK = { @@ -19,6 +21,8 @@ "INFO": 1, } +SEVERITY_ORDER = {"CRITICAL": -1, "HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3, "INFORMATIONAL": 3} + def severity_rank(finding: dict) -> int: return _SEVERITY_RANK.get(str(finding.get("severity", "")).upper(), 0) @@ -27,8 +31,9 @@ def severity_rank(finding: dict) -> int: def _build_summary_prompt(findings: list) -> str: lines = [] for f in findings: + title = f.get("title") or f.get("rule_name") or "Untitled" lines.append( - f"- [{f.get('severity', 'UNKNOWN')}] {f.get('title', 'Untitled')}: {f.get('description', 'No description provided.')}" + f"- [{f.get('severity', 'UNKNOWN')}] {title}: {f.get('description', 'No description provided.')}" ) findings_text = "\n".join(lines) return ( @@ -45,7 +50,7 @@ def _build_question_prompt(sorted_findings: list, question: str) -> str: lines = [] for f in sorted_findings: rule_id = f.get("rule_id", "") - title = f.get("title", "Untitled") + title = f.get("title") or f.get("rule_name") or "Untitled" severity = f.get("severity", "UNKNOWN") description = f.get("description", "No description provided.") remediation = f.get("remediation", "No remediation detail provided.") @@ -72,7 +77,7 @@ def _build_remediation_prompt(sorted_findings: list) -> str: lines = [] for f in sorted_findings: rule_id = f.get("rule_id", "") - title = f.get("title", "Untitled") + title = f.get("title") or f.get("rule_name") or "Untitled" severity = f.get("severity", "UNKNOWN") remediation = f.get("remediation", "No remediation detail provided.") label = f"{rule_id} β€” {title}" if rule_id else title @@ -89,7 +94,41 @@ def _build_remediation_prompt(sorted_findings: list) -> str: ) -@ai_bp.post("/insights") +def _findings_to_text(findings): + ordered = sorted( + findings, + key=lambda f: SEVERITY_ORDER.get(str(f.get("severity", "")).upper(), 4), + ) + lines = [] + for i, f in enumerate(ordered, 1): + lines.append( + f"{i}. [{f.get('severity', 'UNKNOWN')}] " + f"{f.get('rule_name', 'Unknown')} on " + f"{f.get('resource_name', 'unknown resource')}: " + f"{f.get('description', '')}" + ) + return "\n".join(lines) if lines else "No findings." + + +def _context_for(query): + chunks = retrieve(query, n_results=5) + context = "\n".join(f"- ({c['source']}) {c['text']}" for c in chunks) + sources = [c["source"] for c in chunks if c["source"]] + return context, sources + + +def _read_request(): + body = request.get_json(silent=True) + if not body: + return None, (jsonify({"error": "Request body must be JSON"}), 400) + if not body.get("provider"): + return None, (jsonify({"error": "provider is required"}), 400) + if not body.get("api_key"): + return None, (jsonify({"error": "api_key is required"}), 400) + return body, None + + +@ai_bp.post("/api/ai/insights") def insights(): data = request.get_json(silent=True) if data is None: @@ -137,3 +176,127 @@ def insights(): response["answer"] = answer return jsonify(response) + + +@ai_bp.post("/api/ai/summary") +def ai_summary(): + body, error = _read_request() + if error: + return error + findings = body.get("findings", []) + if not isinstance(findings, list): + return jsonify({"error": "findings must be a list"}), 400 + + findings_text = _findings_to_text(findings) + try: + context, sources = _context_for(findings_text) + except VectorStoreNotBuilt as exc: + return jsonify({"error": str(exc)}), 503 + + prompt = ( + "You are a cloud security advisor. Using ONLY the grounded knowledge " + "below, write a plain English executive summary of the security " + "posture for a non technical reader. Keep it under 120 words.\n\n" + f"GROUNDED KNOWLEDGE:\n{context}\n\nFINDINGS:\n{findings_text}" + ) + try: + answer = get_completion( + body["provider"], body["api_key"], prompt, model=body.get("model") + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 502 + + return jsonify({ + "summary": answer, + "sources": sources, + "provider": body["provider"], + "model": body.get("model"), + }) + + +@ai_bp.post("/api/ai/prioritise") +def ai_prioritise(): + body, error = _read_request() + if error: + return error + findings = body.get("findings", []) + if not isinstance(findings, list): + return jsonify({"error": "findings must be a list"}), 400 + + findings_text = _findings_to_text(findings) + try: + context, sources = _context_for(findings_text) + except VectorStoreNotBuilt as exc: + return jsonify({"error": str(exc)}), 503 + + prompt = ( + "You are a cloud security advisor. Using ONLY the grounded knowledge " + "below, rank these findings by real world exploitability and business " + "risk, not just the severity label. Respond with valid JSON only, no " + "markdown, as a list of objects with fields: priority, rule_name, " + "resource_name, severity, reason.\n\n" + f"GROUNDED KNOWLEDGE:\n{context}\n\nFINDINGS:\n{findings_text}" + ) + try: + raw = get_completion( + body["provider"], body["api_key"], prompt, model=body.get("model") + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 502 + + try: + prioritised = json.loads(raw) + except (json.JSONDecodeError, TypeError): + prioritised = raw + + return jsonify({ + "prioritised_findings": prioritised, + "sources": sources, + "provider": body["provider"], + "model": body.get("model"), + }) + + +@ai_bp.post("/api/ai/ask") +def ai_ask(): + body, error = _read_request() + if error: + return error + question = body.get("question", "") + if not question or not question.strip(): + return jsonify({"error": "question is required"}), 400 + + try: + context, sources = _context_for(question) + except VectorStoreNotBuilt as exc: + return jsonify({"error": str(exc)}), 503 + + findings = body.get("findings", []) + findings_text = _findings_to_text(findings) if findings else "Not provided." + + prompt = ( + "You are a cloud security advisor. Answer the question using ONLY the " + "grounded knowledge below. If the answer is not in the knowledge, say " + "so honestly. Reference specific rule IDs or controls where relevant." + f"\n\nGROUNDED KNOWLEDGE:\n{context}\n\n" + f"CURRENT FINDINGS:\n{findings_text}\n\nQUESTION: {question}" + ) + try: + answer = get_completion( + body["provider"], body["api_key"], prompt, model=body.get("model") + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 502 + + return jsonify({ + "answer": answer, + "sources": sources, + "provider": body["provider"], + "model": body.get("model"), + }) diff --git a/api/routes/findings.py b/api/routes/findings.py index 917a23f..2803251 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -5,16 +5,17 @@ from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager +from scanner.cve_correlator import enrich_findings findings_bp = Blueprint("findings", __name__) logger = logging.getLogger(__name__) def _get_db() -> DatabaseManager: - if "db_conn" not in g: - g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) - g.db_conn.connect() - return g.db_conn + if "db" not in g: + g.db = DatabaseManager(os.environ["DATABASE_URL"]) + g.db.connect() + return g.db @findings_bp.get("/api/findings") @@ -22,10 +23,10 @@ def list_findings(): """Return findings, optionally filtered by severity, category, or rule_id. Query parameters: - severity β€” HIGH | MEDIUM | LOW | INFO - category β€” Storage | Network | Identity | Database | Compute | KeyVault - rule_id β€” e.g. AZ-STOR-001 - scan_id β€” UUID of a specific scan + severity - HIGH | MEDIUM | LOW | INFO + category - Storage | Network | Identity | Database | Compute | KeyVault + rule_id - e.g. AZ-STOR-001 + scan_id - UUID of a specific scan """ try: filters = { @@ -35,6 +36,16 @@ def list_findings(): } db = _get_db() findings = db.get_findings(filters) + legacy_findings = [ + f + for f in findings + if f.get("cve_references") is None + and f.get("cvss_score") is None + and f.get("exploit_available") is None + ] + if legacy_findings: + enrich_findings(legacy_findings) + db.update_cve_fields(legacy_findings) return jsonify({"count": len(findings), "findings": findings}) except Exception as exc: logger.error("Failed to list findings: %s", exc) diff --git a/api/routes/score.py b/api/routes/score.py index 190a3ee..9d0e125 100644 --- a/api/routes/score.py +++ b/api/routes/score.py @@ -22,7 +22,7 @@ def _get_db() -> DatabaseManager: @score_bp.get("/api/score") def get_score(): - """Return the overall security posture score (0–100). + """Return the overall security posture score (0-100). Score calculation: Starts at 100. Deducts 10 per HIGH finding, 5 per MEDIUM, 2 per LOW. @@ -34,4 +34,16 @@ def get_score(): return jsonify(result) except Exception as exc: logger.error("Failed to calculate score: %s", exc) - return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500 \ No newline at end of file + return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500 + + +@score_bp.get("/api/score/cve-summary") +def get_cve_summary(): + """Return high-level CVE summary for the dashboard.""" + try: + db = _get_db() + result = db.get_cve_summary() + return jsonify(result) + except Exception as exc: + logger.error("Failed to fetch CVE summary: %s", exc) + return jsonify({"error": "Failed to fetch CVE summary", "detail": str(exc)}), 500 diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index b1e11b6..aedfde7 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -107,7 +107,7 @@ "control_id": "8.3", "control_name": "Ensure that 'OS patching' is enabled for virtual machines", "description": "The virtual machine does not have automatic OS patching enabled. CIS 8.3 requires that OS patches are applied in a timely manner. Unpatched VMs are vulnerable to known exploits targeting unpatched OS vulnerabilities." - }, + }, "AZ-KV-001": { "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index f061821..f9e3f97 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -98,11 +98,6 @@ "control_name": "Policy on the use of cryptographic controls", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented." }, - "AZ-CMP-003": { - "control_id": "A.12.2.1", - "control_name": "Controls against malware", - "description": "The virtual machine does not have a recognised endpoint protection extension installed. A.12.2.1 requires that detection, prevention and recovery controls are implemented to protect against malware." - }, "AZ-CMP-004": { "control_id": "A.12.6.1", "control_name": "Management of technical vulnerabilities", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 4f40795..9d243e6 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -18,6 +18,11 @@ "control_name": "Change Management", "description": "A storage account with no lifecycle management policy allows data to accumulate indefinitely with no automatic expiry or tiering. CC8.1 requires that infrastructure and data are managed through formal processes. Implementing a lifecycle policy ensures data retention is controlled and old data is automatically moved or deleted according to organisational policy." }, + "AZ-STOR-004": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Azure Monitor diagnostic logging must be enabled for all storage account services (blob, queue, table) to ensure that security-relevant events are recorded. CC7.2 requires that the entity monitors the system and takes action to maintain compliance. Without full logging, unauthorized access or data exfiltration attempts may go undetected." + }, "AZ-STOR-005": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", @@ -108,21 +113,11 @@ "control_name": "Protects Data in Transit and At Rest", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed keys lack customer control and audit capabilities needed for compliance." }, - "AZ-CMP-003": { - "control_id": "CC6.8", - "control_name": "Prevents or Detects Unauthorized or Malicious Software", - "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorised or malicious software." - }, "AZ-CMP-004": { "control_id": "CC7.1", "control_name": "System Vulnerabilities are Identified and Managed", "description": "The virtual machine does not have automatic OS patching enabled. CC7.1 requires that vulnerabilities in system components are identified and managed through a defined process. Without automatic patching, known OS vulnerabilities are left unmitigated and exploitable." }, - "AZ-CMP-003": { - "control_id": "CC6.8", - "control_name": "Prevents or Detects Unauthorized or Malicious Software", - "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorized or malicious software. Without endpoint protection, malicious code executing on the VM will not be detected or blocked." - }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", @@ -172,7 +167,6 @@ "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", "description": "VNet peering with allowGatewayTransit or useRemoteGateways enabled allows traffic to cross network boundaries through shared gateways, weakening the logical separation between network zones. CC6.6 requires that logical access from outside the network boundary is restricted and controlled. Gateway transit on peering connections should be disabled to enforce boundary separation." - "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall creates a rule that permits any Azure-hosted resource β€” including services from other tenants β€” to connect to the database. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Disabling this setting and replacing it with explicit firewall rules or private endpoints enforces the network boundary and ensures only known and trusted systems can reach the SQL Server." } } } diff --git a/docs/api-render-deploy.md b/docs/api-render-deploy.md index a1ed3b5..b9685f0 100644 --- a/docs/api-render-deploy.md +++ b/docs/api-render-deploy.md @@ -31,10 +31,14 @@ To ensure the highest reliability of the deployment while accommodating free-tie ### 2.2 Token Generation Method * **Dynamic HS256 Signing:** Instead of using a hardcoded dummy string, the test script dynamically generates a real token signed with the environment's `JWT_SECRET`. -* **Default Secret Alignment:** The smoke test defaults to `change-me-in-production`, matching the API's default. This allows tests to run "out of the box" in local environments without extra configuration. +* **Explicit `JWT_SECRET` Required:** The smoke test no longer falls back to the insecure default. `JWT_SECRET` must be set explicitly before running the script, whether locally or in CI. > [!CAUTION] -> **ABSOLUTE SECURITY REQUIREMENT:** For any production deployment (Render, Azure, etc.), you **MUST** override the default `JWT_SECRET` with a long, random, and unique string. Leaving the default value in place makes your API vulnerable to unauthorized access via token forging. +> **PRODUCTION FAIL-CLOSED:** The API refuses to start in production (`OPENSHIELD_ENV=production` or `RENDER=true`) if `JWT_SECRET` is missing, equals the known default `change-me-in-production`, or is shorter than 32 characters. Generate a strong secret with: +> ``` +> python -c "import secrets; print(secrets.token_urlsafe(32))" +> ``` +> Local development runs (no production signal set) are allowed to use the default and will log a loud warning. ### 2.3 API Smoke Test Strategy (The 23 Cases) The 23 test cases were selected to prove the API is structurally sound and resilient: diff --git a/docs/azure-setup.md b/docs/azure-setup.md index 5c672d9..df52074 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -109,7 +109,10 @@ AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx AZURE_CLIENT_SECRET=your-client-secret-from-step-3 AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +# Must be at least 32 characters. The app refuses to start in production without it. JWT_SECRET=your-random-secret-at-least-32-chars +OPENSHIELD_ENV=production SENTINEL_WORKSPACE_ID= SENTINEL_SHARED_KEY= SENTINEL_LOG_TYPE=OpenShieldFindings diff --git a/docs/cve_correlation_feature.md b/docs/cve_correlation_feature.md new file mode 100644 index 0000000..c1836eb --- /dev/null +++ b/docs/cve_correlation_feature.md @@ -0,0 +1,114 @@ +# OpenShield - CVE Correlation Feature Documentation + +## Overview + +The CVE Correlation feature integrates the MITRE National Vulnerability Database (NVD) API with the OpenShield scanner. It cross-references security misconfigurations discovered during scans with known Common Vulnerabilities and Exposures (CVEs), providing users with CVSS scores and exploit availability status. + +## Files Created and Modified + +### New Files (Core Logic) + +| File | Purpose | +|---|---| +| scanner/nvd_client.py | NVD API Integration. Handles low-level communication with MITRE NVD. Implements strict rate-limiting (7s gap), in-memory caching for performance, and exponential back-off for reliability. | +| scanner/cve_correlator.py | Contextual Mapping. Maps OpenShield Rule IDs (e.g., AZ-STOR) to NVD search terms. Performs the logic of merging raw API results into finding objects. | +| tests/test_cve_correlator.py | Logic Verification. Unit tests ensuring Rule IDs map correctly and finding enrichment correctly identifies the highest risk. | + +### Modified Files (Integration) + +| File | Change | Why | +|---|---|---| +| scanner/engine.py | Enrichment-at-Source. Integrated enrich_findings directly into the scan lifecycle. | Performance: By enriching during the scan, CVE data is saved once to the database. The frontend does not have to wait for an NVD API call when loading the dashboard. | +| api/models/finding.py | Updated Finding dataclass and added run_migrations and get_cve_summary. | Persistence: Adds cve_references, cvss_score, and exploit_available columns to PostgreSQL. get_cve_summary provides stats for dashboard widgets. | +| api/app.py | Added db.run_migrations call at startup. | Auto-Deployment: Ensures the database schema is updated automatically on any environment where the app is launched. | +| api/routes/score.py | Added GET /api/score/cve-summary endpoint. | Dashboard UI: Provides the frontend with high-level data like Total Known Exploits in a single lightweight request. | +| api/routes/findings.py | Returns findings from the database and enriches only legacy rows missing CVE fields. | Performance: Avoids extra NVD calls on every request while still backfilling older records. | + +## Frontend Integration Design + +To ensure the frontend dashboard works perfectly, the architecture uses an Enrichment-at-Source model: + +1. Zero-Latency Dashboard Loads: The scan engine pre-enriches findings. When the frontend calls the API, it receives static data from the database. Legacy rows missing CVE fields are enriched on-demand only once. +2. Dashboard-Ready Summary Endpoint: The /api/score/cve-summary endpoint allows the frontend to fetch high-level statistics (Total Findings, Exploit Count, Max CVSS) in one call instead of processing thousands of records locally. +3. Actionable Risk (CISA KEV): The exploit_available flag uses the CISA Known Exploited Vulnerabilities catalogue, allowing the dashboard to highlight high-priority risks that are being exploited in the wild. +4. Persistent Historical State: Enrichment happens at the time of scan, meaning the dashboard shows the CVE status as it existed on that day. This ensures accurate compliance and historical reporting. + +## Security and Compliance Audit + +1. No Hardcoded Secrets: All credentials (DATABASE_URL, JWT_SECRET) are handled via environment variables. +2. SSRF Protection: NVD query parameters are sanitized and derived from internal static maps. +3. SQL Safety: All database additions use parameterized queries to prevent injection. +4. Character Quality: All non-ASCII characters and emojis were removed for pipeline compatibility. + +## Frontend-Ready API Responses + +### GET /api/findings + +Response shape (abridged): + +```json +{ + "count": 2, + "findings": [ + { + "id": 123, + "rule_id": "AZ-STOR-003", + "severity": "HIGH", + "resource_id": "/subscriptions/...", + "cve_references": [ + { + "cve_id": "CVE-2023-12345", + "cvss_score": 9.8, + "cvss_severity": "CRITICAL", + "exploit_available": true, + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2023-12345" + } + ], + "cvss_score": 9.8, + "exploit_available": true + } + ] +} +``` + +Notes: +1. Results are ordered by detected_at descending and capped at 1000. +2. CVE fields are always present. Legacy rows are backfilled on request. + +### GET /api/score/cve-summary + +Response shape: + +```json +{ + "total_findings": 74, + "exploit_count": 5, + "max_cvss_score": 9.8, + "avg_cvss_score": 6.42, + "critical_cve_count": 3 +} +``` + +## Testing Strategy + +All logic is verified using the Python standard library unittest framework. All NVD HTTP calls are fully mocked to ensure stability. + +### Testing Rationale + +The tests focus on the correlator behavior with all NVD calls mocked: + +1. Keyword Mapping (TestGetNvdKeyword): + * Purpose: Ensure rule_id values resolve to a stable NVD keyword. + * Rationale: Prefix fallback prevents gaps when new rules are added. + +2. Enrichment Logic (TestEnrichSingleFinding, TestEnrichFindings): + * Purpose: Validate cve_references, cvss_score, and exploit_available handling. + * Rationale: Ensures highest CVSS is selected and output order is preserved. + +### How to run the tests + +```bash +python3 -m unittest tests/test_cve_correlator.py -v +``` + +Expected output: All tests passing, zero network calls made. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f52de67 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# 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? +.vite diff --git a/frontend/.gitkeep b/frontend/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/API_ENDPOINTS.txt b/frontend/API_ENDPOINTS.txt new file mode 100644 index 0000000..b0433d3 --- /dev/null +++ b/frontend/API_ENDPOINTS.txt @@ -0,0 +1,1104 @@ +================================================================================ + OPENSHIELD β€” BACKEND API ENDPOINTS REFERENCE + Frontend contract file | Last updated: 2026-06-01 +================================================================================ + + Base URL : http://localhost:5001 (set via VITE_API_URL in .env.local) + Auth : Bearer (stored in localStorage key "jwt_token") + Format : JSON (Content-Type: application/json) + + All protected endpoints need the Authorization header: + Authorization: Bearer dev-demo-token + +================================================================================ + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 1. HEALTH CHECK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Simple ping to check if the backend server is running. + The frontend calls this automatically when you switch from Demo β†’ Live mode. + If it fails, the app stays in Demo mode and shows an error popup. + + Request + ─────── + GET /health + (No authentication required) + + Response + ──────── + { + "status": "ok" + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 2. SECURITY SCORE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Returns the overall security score for the Azure environment. + Shown as the big number in the donut chart on the Monitoring page. + Score is 0–100. Lower is worse. Target is 80+. + + Request + ─────── + GET /api/score + Authorization: Bearer + + Response + ──────── + { + "score": 68, + "max_score": 100 + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 3. LIST ALL FINDINGS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Returns every security finding (misconfiguration or vulnerability) found + across all Azure resources. Used on the Scan page, AI page, and to count + issues per resource on the Discovery page. + + Request + ─────── + GET /api/findings + Authorization: Bearer + + Optional query parameters: + ?limit=100 How many findings to return (default 100) + ?offset=0 Skip this many findings (for pagination) + ?severity=HIGH Filter to HIGH, MEDIUM, or LOW only + ?category=Network Filter to one category (Storage, Compute, Network, etc.) + ?rule_id=AZ-NET-001 Filter to one specific rule + + Examples: + GET /api/findings?severity=HIGH + GET /api/findings?limit=10&offset=20 + GET /api/findings?category=Storage&severity=HIGH + + Response + ──────── + { + "count": 25, + "limit": 100, + "offset": 0, + "findings": [ + { + "id": 1, + "rule_id": "AZ-STOR-001", + "rule_name": "Storage allows public blob access", + "severity": "HIGH", + "category": "Storage", + "resource_id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/prod-storage-01", + "resource_name": "prod-storage-01", + "resource_type": "Microsoft.Storage/storageAccounts", + "description": "Storage account allows anonymous public read access", + "remediation": "Disable public blob access at the storage account level", + "detected_at": "2026-05-28T10:00:00Z" + }, + { + "id": 2, + "rule_id": "AZ-NET-001", + "rule_name": "NSG allows unrestricted SSH", + "severity": "HIGH", + "category": "Network", + "resource_id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Network/networkSecurityGroups/nsg-web", + "resource_name": "nsg-web", + "resource_type": "Microsoft.Network/networkSecurityGroups", + "description": "Port 22 (SSH) open to 0.0.0.0/0", + "remediation": "Restrict SSH to specific trusted IP ranges", + "detected_at": "2026-05-28T13:00:00Z" + } + ] + } + + Severity values: HIGH | MEDIUM | LOW + Category values: Storage | Compute | Network | Identity | Database | KeyVault | Monitoring + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 4. SINGLE FINDING +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Returns the details for one specific finding by its ID. + Called when a user clicks a finding on the Scan page to see + the full remediation playbook. + + Request + ─────── + GET /api/findings/1 + Authorization: Bearer + + Response + ──────── + { + "id": 1, + "rule_id": "AZ-STOR-001", + "rule_name": "Storage allows public blob access", + "severity": "HIGH", + "category": "Storage", + "resource_id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/prod-storage-01", + "resource_name": "prod-storage-01", + "resource_type": "Microsoft.Storage/storageAccounts", + "description": "Storage account allows anonymous public read access", + "remediation": "Disable public blob access at the storage account level", + "detected_at": "2026-05-28T10:00:00Z" + } + + Note: The frontend enriches this with portal steps and CLI commands + from its internal playbook library (scan.json). + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 5. SCAN HISTORY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Returns the list of past scans that have been run. + Each scan has a start time, end time, and how many findings it found. + + Request + ─────── + GET /api/scans + Authorization: Bearer + + Response + ──────── + { + "count": 3, + "scans": [ + { + "scan_id": "scan-001-20260529", + "subscription_id": "sub-123", + "started_at": "2026-05-29T14:00:00Z", + "completed_at": "2026-05-29T14:05:23Z", + "total_findings": 25, + "status": "completed" + }, + { + "scan_id": "scan-002-20260528", + "subscription_id": "sub-123", + "started_at": "2026-05-28T10:00:00Z", + "completed_at": "2026-05-28T10:08:41Z", + "total_findings": 24, + "status": "completed" + } + ] + } + + Status values: pending | running | completed | failed + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 6. TRIGGER A NEW SCAN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Tells the backend to start scanning the Azure subscription right now. + Returns immediately with a scan ID. The scan runs in the background. + Poll GET /api/scans to check when it completes. + + Request + ─────── + POST /api/scans/trigger + Authorization: Bearer + Content-Type: application/json + + Body (optional β€” omit to scan the default subscription): + { + "subscription_id": "sub-123" + } + + Response + ──────── + { + "scan_id": "scan-new-20260601", + "subscription_id": "sub-123", + "started_at": "2026-06-01T10:00:00Z", + "completed_at": "2026-06-01T10:05:47Z", + "total_findings": 25, + "status": "completed" + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 7. COMPLIANCE β€” CIS AZURE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Returns how many CIS Azure Benchmark controls the environment passes or fails. + CIS is a widely-used security checklist published by the Center for Internet + Security. Each control maps to one of our rules (e.g. CIS 6.2 = AZ-NET-001). + + Request + ─────── + GET /api/compliance/cis + Authorization: Bearer + + Response + ──────── + { + "framework": "CIS Microsoft Azure Foundations Benchmark", + "version": "2.0.0", + "score_percent": 74, + "passed": 7, + "failed": 6, + "total_controls": 13, + "controls": [ + { + "control_id": "3.5", + "control_name": "Ensure public access is disabled on all storage accounts", + "rule_id": "AZ-STOR-001", + "status": "FAIL" + }, + { + "control_id": "6.2", + "control_name": "Ensure SSH access is restricted from the internet", + "rule_id": "AZ-NET-001", + "status": "FAIL" + }, + { + "control_id": "1.1", + "control_name": "Ensure Security Defaults are enabled on Azure Active Directory", + "rule_id": null, + "status": "PASS" + } + ] + } + + Status values: PASS | FAIL + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 8. COMPLIANCE β€” NIST SP 800-53 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Same as CIS but mapped to the NIST SP 800-53 framework instead. + NIST is the US government security standard used by federal agencies + and many enterprises. + + Request + ─────── + GET /api/compliance/nist + Authorization: Bearer + + Response + ──────── + { + "framework": "NIST SP 800-53 Rev 5", + "version": "5.0.0", + "score_percent": 68, + "passed": 17, + "failed": 8, + "total_controls": 25, + "controls": [ + { + "control_id": "AC-3", + "control_name": "Access Enforcement", + "rule_id": "AZ-STOR-001", + "status": "FAIL" + }, + { + "control_id": "SC-8", + "control_name": "Transmission Confidentiality and Integrity", + "rule_id": "AZ-STOR-002", + "status": "FAIL" + }, + { + "control_id": "IA-5", + "control_name": "Authenticator Management", + "rule_id": null, + "status": "PASS" + } + ] + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 9. COMPLIANCE β€” ISO 27001 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Same as CIS but mapped to the ISO 27001:2022 international standard. + ISO 27001 is the global benchmark for information security management. + + Request + ─────── + GET /api/compliance/iso27001 + Authorization: Bearer + + Response + ──────── + { + "framework": "ISO 27001:2022", + "version": "2022", + "score_percent": 81, + "passed": 18, + "failed": 4, + "total_controls": 22, + "controls": [ + { + "control_id": "A.10.1.1", + "control_name": "Policy on the use of cryptographic controls", + "rule_id": "AZ-STOR-002", + "status": "FAIL" + }, + { + "control_id": "A.12.3.1", + "control_name": "Information backup", + "rule_id": null, + "status": "PASS" + } + ] + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 10. AI CHAT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Sends a question to the AI and gets back an answer. + The AI uses RAG (Retrieval-Augmented Generation) to look up relevant + findings from the database and answer in context. + Used on the AI Assistant page. + + Request + ─────── + POST /api/ai/chat + Authorization: Bearer + Content-Type: application/json + + Body: + { + "question": "How do I fix the SSH vulnerability on nsg-web?", + "context": { + "rule_id": "AZ-NET-001", + "resource_name": "nsg-web" + } + } + + Note: "context" is optional. When omitted, the AI answers about the + full environment. When provided, it focuses on that specific finding. + + Response + ──────── + { + "answer": "To fix the SSH vulnerability on nsg-web, delete the inbound rule allowing port 22 from 0.0.0.0/0 and replace it with a rule restricted to your VPN CIDR...", + "sources": [ + { "id": "AZ-NET-001", "resource": "nsg-web" } + ] + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 11. AI EXECUTIVE SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Asks the AI to generate a short executive summary of the current + security posture. Returns the top 3 priorities and an estimate of + how long it would take to fix everything. Shown in the right panel + of the AI Assistant page. + + Request + ─────── + GET /api/ai/summary + Authorization: Bearer + + Response + ──────── + { + "generated_at": "2026-06-01T06:00:00Z", + "risk_score": 68, + "trend": "improving", + "overview": "Your Azure environment has 25 open findings. The most critical exposures are internet-accessible SSH/RDP ports and an open SQL database firewall.", + "top_priorities": [ + { + "rank": 1, + "title": "Close SSH/RDP ports open to 0.0.0.0/0", + "impact": "CRITICAL", + "eta": "2 hours", + "rule_id": "AZ-NET-001", + "resource": "nsg-web, nsg-app" + }, + { + "rank": 2, + "title": "Delete AllowAllIPs SQL firewall rule", + "impact": "CRITICAL", + "eta": "30 mins", + "rule_id": "AZ-DB-001", + "resource": "sql-dev-exposed" + } + ], + "estimated_remediation_time": "3-5 business days", + "compliance_status": { + "cis": 74, + "nist": 68, + "iso27001": 81 + } + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 12. AI CVE ANALYSIS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + What it does: + Returns a list of known CVEs (Common Vulnerabilities and Exposures) + that affect resources in the environment. For example, if a VM is + running an unpatched Windows Server, this lists the specific CVE IDs + and their severity scores. Shown in the CVE Analysis panel on the AI page. + + Request + ─────── + GET /api/ai/cve-analysis + Authorization: Bearer + + Response + ──────── + { + "last_updated": "2026-06-01T06:00:00Z", + "total": 5, + "cves": [ + { + "id": "CVE-2024-38077", + "name": "Windows RDL Remote Code Execution", + "description": "Critical RCE in Windows Remote Desktop Licensing Service. No authentication required.", + "cvss_score": 9.8, + "severity": "CRITICAL", + "affected_resources": ["vm-web-01"], + "affected_count": 1, + "patch_available": true, + "remediation": "Apply Microsoft security update KB5040442", + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2024-38077", + "published_date": "2024-07-09" + }, + { + "id": "CVE-2023-23397", + "name": "Microsoft Outlook NTLM Hash Leak", + "description": "Zero-click vulnerability, no user interaction required.", + "cvss_score": 9.8, + "severity": "CRITICAL", + "affected_resources": ["vm-web-01"], + "affected_count": 1, + "patch_available": true, + "remediation": "Apply Microsoft security update KB5023745", + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2023-23397", + "published_date": "2023-03-14" + } + ] + } + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 13. RESOURCE DISCOVERY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Used by: Discovery page + + What it does: + Returns every Azure resource that has been discovered across all + subscriptions and resource groups. Each resource has a risk level + (HIGH / MEDIUM / LOW / NONE) based on the worst finding attached to it. + The page lets users filter by category, risk, location, and resource group. + + Request + ─────── + GET /api/resources + Authorization: Bearer + + Optional query parameters: + ?subscription_id=sub-123 Filter to one subscription + ?resource_group=rg-prod Filter to one resource group + ?category=Storage Filter by category + ?risk=HIGH Filter by risk level (HIGH|MEDIUM|LOW|NONE) + ?location=eastus Filter by Azure region + + Response + ──────── + { + "summary": { + "total": 17, + "by_category": { + "Storage": 4, + "Compute": 3, + "Network": 4, + "Identity": 1, + "Database": 3, + "KeyVault": 1, + "Monitoring": 1 + }, + "by_risk_level": { + "HIGH": 7, + "MEDIUM": 4, + "LOW": 4, + "NONE": 2 + }, + "last_scan_at": "2026-05-29T18:00:00Z" + }, + "resources": [ + { + "id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/prod-storage-01", + "name": "prod-storage-01", + "type": "Microsoft.Storage/storageAccounts", + "category": "Storage", + "resource_group": "rg-prod", + "subscription_id": "sub-123", + "location": "eastus", + "risk": "HIGH", + "discovered_at": "2026-05-28T10:00:00Z" + }, + { + "id": "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.Network/networkSecurityGroups/nsg-web", + "name": "nsg-web", + "type": "Microsoft.Network/networkSecurityGroups", + "category": "Network", + "resource_group": "rg-prod", + "subscription_id": "sub-123", + "location": "eastus", + "risk": "HIGH", + "discovered_at": "2026-05-28T10:10:00Z" + } + ] + } + + Risk values: HIGH | MEDIUM | LOW | NONE + Category values: Storage | Compute | Network | Identity | Database | KeyVault | Monitoring + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 14. FINDING REMEDIATION PLAYBOOK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Used by: Detailed Scan page, AI Assistant page + + What it does: + Returns the full step-by-step remediation guide for one specific finding. + This includes portal steps (what to click in Azure Portal), CLI commands + (az commands to copy-paste), validation steps (how to confirm the fix + worked), and compliance references. + + The Detailed Scan page shows this as a tabbed panel: Portal Steps | CLI | Validation. + + Request + ─────── + GET /api/findings/:id/playbook + Authorization: Bearer + + Example: + GET /api/findings/4/playbook + + Response + ──────── + { + "finding_id": 4, + "rule_id": "AZ-NET-001", + "rule_name": "NSG allows unrestricted SSH", + "resource_name": "nsg-web", + "resource_group": "rg-prod", + "portal_steps": [ + "Open the Azure Portal and navigate to Network Security Groups", + "Select 'nsg-web' and click 'Inbound security rules'", + "Find the rule allowing port 22 from source 0.0.0.0/0", + "Change the Source from 'Any' to your VPN CIDR (e.g. 10.0.0.0/8)", + "Click Save β€” change takes effect within seconds" + ], + "cli_commands": [ + "az network nsg rule delete --resource-group rg-prod --nsg-name nsg-web --name Allow-SSH-Any", + "az network nsg rule create --resource-group rg-prod --nsg-name nsg-web --name Allow-SSH-VPN --priority 200 --source-address-prefixes 10.0.0.0/8 --destination-port-ranges 22 --access Allow --protocol Tcp" + ], + "validation_steps": [ + "Run: az network nsg rule list --nsg-name nsg-web --resource-group rg-prod", + "Confirm no rule shows Source: * and Port: 22", + "Test SSH from an IP outside your allowed range β€” connection should time out" + ], + "references": [ + "CIS Azure 6.2", + "NIST SP 800-53 AC-17" + ] + } + + Frontend behaviour: + The frontend calls GET /api/findings/:id/playbook every time a user selects + a finding on the Scan page. If the endpoint returns an error or doesn't exist + yet, it automatically falls back to the internal playbook library (scan.json). + Once you implement this endpoint, it takes over with no frontend changes needed. + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 15. RISK PRIORITIZATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Used by: Prioritization page + + What it does: + Returns all findings ranked by a priority score that factors in both + risk (how dangerous is it?) and effort (how hard is it to fix?). + High risk + low effort = fix first. Also returns a matrix of all findings + plotted on risk vs effort axes, and a list of concrete action items. + + Request + ─────── + GET /api/prioritization + Authorization: Bearer + + Optional query parameters: + ?category=Network Filter to one category + ?severity=HIGH Filter to one severity level + + Response + ──────── + { + "matrix": [ + { + "id": 1, + "rule_id": "AZ-STOR-001", + "name": "Storage allows public blob access", + "risk": 9, + "effort": 1, + "category": "Storage", + "severity": "HIGH", + "resource": "prod-storage-01" + }, + { + "id": 4, + "rule_id": "AZ-NET-001", + "name": "NSG allows unrestricted SSH", + "risk": 9, + "effort": 2, + "category": "Network", + "severity": "HIGH", + "resource": "nsg-web" + } + ], + "rankings": [ + { + "rank": 1, + "rule_id": "AZ-NET-001", + "name": "SSH (port 22) open to 0.0.0.0/0 on nsg-web", + "score": 98, + "severity": "HIGH", + "category": "Network", + "effort": 2, + "impact": "CRITICAL", + "resource": "nsg-web" + }, + { + "rank": 2, + "rule_id": "AZ-DB-001", + "name": "SQL database fully public on sql-dev-exposed", + "score": 97, + "severity": "HIGH", + "category": "Database", + "effort": 1, + "impact": "CRITICAL", + "resource": "sql-dev-exposed" + } + ], + "action_items": [ + { + "id": 1, + "action": "Restrict SSH/RDP NSG rules to VPN CIDR on nsg-web and nsg-app", + "impact": "HIGH", + "effort": "LOW", + "eta": "1 hour", + "rule_id": "AZ-NET-001", + "resource": "nsg-web" + }, + { + "id": 2, + "action": "Delete AllowAllIPs firewall rule on sql-dev-exposed", + "impact": "HIGH", + "effort": "LOW", + "eta": "30 mins", + "rule_id": "AZ-DB-001", + "resource": "sql-dev-exposed" + } + ] + } + + risk field: 1–10 (10 = most dangerous) + effort field: 1–5 (1 = easiest to fix, 5 = hardest) + score field: 0–100 overall priority score + impact values: CRITICAL | HIGH | MEDIUM | LOW + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 16. CONFIGURATION DRIFT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Used by: Drift page + + What it does: + Returns a timeline of all configuration changes detected in the Azure + environment. Each event shows what changed, on which resource, who made + the change, and whether it violated a security rule (creating a new finding). + + Typical drift events: + - Someone opened a port in an NSG (MODIFIED) + - A new VM was spun up without a security policy (ADDED) + - A storage account was deleted (REMOVED) + + Request + ─────── + GET /api/drift + Authorization: Bearer + + Optional query parameters: + ?type=MODIFIED Filter by change type (ADDED|REMOVED|MODIFIED) + ?severity=HIGH Filter by severity of the change + ?resource_group=rg-prod Filter to one resource group + ?from=2026-05-28T00:00:00Z Changes after this timestamp + ?to=2026-05-30T00:00:00Z Changes before this timestamp + + Response + ──────── + { + "summary": { + "total": 10, + "added": 3, + "removed": 2, + "modified": 5, + "last_checked": "2026-05-29T18:00:00Z" + }, + "events": [ + { + "id": 1, + "type": "MODIFIED", + "severity": "HIGH", + "resource_name": "prod-storage-01", + "resource_type": "Microsoft.Storage/storageAccounts", + "resource_group": "rg-prod", + "field": "allowBlobPublicAccess", + "old_value": "false", + "new_value": "true", + "changed_by": "john.doe@company.com", + "changed_at": "2026-05-29T14:32:00Z", + "rule_violated": "AZ-STOR-001" + }, + { + "id": 2, + "type": "MODIFIED", + "severity": "HIGH", + "resource_name": "nsg-web", + "resource_type": "Microsoft.Network/networkSecurityGroups", + "resource_group": "rg-prod", + "field": "inboundRules[Allow-SSH].sourceAddressPrefix", + "old_value": "10.0.0.0/8", + "new_value": "0.0.0.0/0", + "changed_by": "jane.smith@company.com", + "changed_at": "2026-05-29T11:15:00Z", + "rule_violated": "AZ-NET-001" + }, + { + "id": 5, + "type": "REMOVED", + "severity": "LOW", + "resource_name": "nsg-legacy-dev", + "resource_type": "Microsoft.Network/networkSecurityGroups", + "resource_group": "rg-dev", + "field": "resource", + "old_value": "existed", + "new_value": null, + "changed_by": "terraform-automation@company.com", + "changed_at": "2026-05-28T18:00:00Z", + "rule_violated": null + } + ] + } + + type values: ADDED | REMOVED | MODIFIED + severity values: HIGH | MEDIUM | LOW + rule_violated: rule ID if this change created a finding, null if safe change + + +================================================================================ + QUICK REFERENCE + IMPLEMENTATION STATUS +================================================================================ + + STATUS KEY: + βœ… Frontend wired up β€” calls real endpoint, falls back to mock if it fails + πŸ”Ά Mock only β€” no backend endpoint defined yet, uses internal mock data + πŸ“„ Mock file ready β€” api.*.json exists and matches expected response format + + METHOD ENDPOINT AUTH STATUS WHAT IT RETURNS + ────── ───────────────────────── ───── ────── ──────────────────────────────── + GET /health No βœ… πŸ“„ { status: "ok" } + GET /api/score Yes βœ… πŸ“„ Overall security score 0-100 + GET /api/resources Yes βœ… All Azure resources + summary + GET /api/findings Yes βœ… πŸ“„ All findings (paginated + filters) + GET /api/findings/:id Yes βœ… πŸ“„ One finding by ID + GET /api/findings/:id/playbook Yes βœ… Portal steps, CLI, validation + GET /api/scans Yes βœ… πŸ“„ History of past scans + GET /api/scans/:id Yes βœ… Status of one specific scan + POST /api/scans/trigger Yes βœ… πŸ“„ Start a new scan + poll for result + GET /api/prioritization Yes βœ… Risk-ranked findings + matrix + GET /api/drift Yes βœ… Configuration change timeline + GET /api/compliance/cis Yes βœ… πŸ“„ CIS Azure controls pass/fail + GET /api/compliance/nist Yes βœ… πŸ“„ NIST SP 800-53 controls pass/fail + GET /api/compliance/iso27001 Yes βœ… πŸ“„ ISO 27001 controls pass/fail + POST /api/ai/chat Yes βœ… AI answer to a question + GET /api/ai/summary Yes βœ… AI-generated executive summary + GET /api/ai/cve-analysis Yes βœ… CVEs affecting your environment + GET /api/monitoring β€” πŸ”Ά Score trend + category breakdown + (no endpoint defined β€” uses mock) + + πŸ“„ = mock file in frontend/src/mockData/api.*.json matches exact response format + βœ… = wired up in frontend/src/utils/api.js with real fetch + mock fallback + + +================================================================================ + FIELD NAMING CONVENTION +================================================================================ + + Backend returns snake_case. Frontend converts to camelCase automatically. + + snake_case (backend) camelCase (frontend) + ───────────────────── ──────────────────────── + rule_id β†’ ruleId + rule_name β†’ ruleName + resource_id β†’ resourceId + resource_name β†’ resourceName + resource_type β†’ resourceType + resource_group β†’ resourceGroup + subscription_id β†’ subscription + detected_at β†’ detectedAt + discovered_at β†’ discoveredAt + + portal_steps β†’ portalSteps + cli_commands β†’ cliCommands + validation_steps β†’ validationSteps + + action_items β†’ actionItems + affected_resources β†’ affectedResources + + old_value β†’ oldValue + new_value β†’ newValue + changed_by β†’ changedBy + changed_at β†’ changedAt + rule_violated β†’ ruleViolated + last_checked β†’ lastChecked + + score_percent β†’ score (renamed for display) + total_controls β†’ totalControls + by_category β†’ byCategory + by_risk_level β†’ byRiskLevel + last_scan_at β†’ lastScanAt + + cvss_score β†’ cvssScore + affected_count β†’ affectedCount + patch_available β†’ patchAvailable + published_date β†’ publishedDate + nvd_url β†’ nvdUrl + + generated_at β†’ generatedAt + risk_score β†’ riskScore + top_priorities β†’ topPriorities + estimated_remediation_time β†’ estimatedRemediationTime + compliance_status β†’ complianceStatus + last_updated β†’ lastUpdated + + +================================================================================ + BACKEND IMPLEMENTATION GUIDE (feat/flask-api branch) +================================================================================ + + This section maps each endpoint to a backend task, database table, and + answers the question: "does this data live in the DB or is it computed?" + + Branch: feat/flask-api + Stack: Flask + PostgreSQL (Render free tier) + +──────────────────────────────────────────────────────────────────────────────── + SPRINT SCOPE β€” BUILD THESE NOW +──────────────────────────────────────────────────────────────────────────────── + + These are the endpoints explicitly listed in the backend task. + The frontend is already wired to call them (falls back to mock until live). + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Endpoint β”‚ File β”‚ Database β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ GET /health β”‚ api/app.py β”‚ None β€” returns "ok" β”‚ + β”‚ GET /api/score β”‚ api/routes/score.py β”‚ READ findings β”‚ + β”‚ GET /api/findings β”‚ api/routes/ β”‚ READ findings+rules β”‚ + β”‚ GET /api/findings/:id β”‚ findings.py β”‚ READ findings+rules β”‚ + β”‚ GET /api/scans β”‚ api/routes/scans.py β”‚ READ scans β”‚ + β”‚ GET /api/scans/:id β”‚ api/routes/scans.py β”‚ READ scans β”‚ + β”‚ POST /api/scans/trigger β”‚ api/routes/scans.py β”‚ WRITE scans β”‚ + β”‚ GET /api/compliance/cis β”‚ api/routes/ β”‚ READ findings+rules β”‚ + β”‚ GET /api/compliance/nist β”‚ compliance.py β”‚ READ findings+rules β”‚ + β”‚ GET /api/compliance/iso27001β”‚ β”‚ READ findings+rules β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +──────────────────────────────────────────────────────────────────────────────── + DEFERRED β€” DO NOT BUILD YET +──────────────────────────────────────────────────────────────────────────────── + + These endpoints exist in the API contract and the frontend calls them, + but they are NOT part of the current sprint. Frontend falls back to mock. + + Endpoint Why deferred + ────────────────────────────── ──────────────────────────────────────────── + GET /api/resources Needs a `resources` table (not in schema yet) + GET /api/findings/:id/playbook Needs a `playbooks` table (not in schema yet) + GET /api/prioritization Computed endpoint β€” build after core is done + GET /api/drift Needs a `drift_events` table (not in schema) + POST /api/ai/chat AI service β€” separate task entirely + GET /api/ai/summary AI service β€” separate task entirely + GET /api/ai/cve-analysis AI service β€” separate task entirely + +──────────────────────────────────────────────────────────────────────────────── + DATABASE SCHEMA (what to create in PostgreSQL) +──────────────────────────────────────────────────────────────────────────────── + + Table: findings + ─────────────── + id SERIAL PRIMARY KEY + rule_id VARCHAR(20) NOT NULL e.g. "AZ-STOR-001" + severity VARCHAR(10) NOT NULL HIGH | MEDIUM | LOW | INFO + resource_id TEXT NOT NULL Full Azure resource path + resource_name VARCHAR(100) NOT NULL e.g. "prod-storage-01" + resource_type VARCHAR(100) e.g. "Microsoft.Storage/storageAccounts" + resource_group VARCHAR(50) e.g. "rg-prod" + category VARCHAR(30) Storage | Compute | Network | etc. + description TEXT + remediation TEXT + status VARCHAR(20) DEFAULT 'open' open | resolved | suppressed + detected_at TIMESTAMP DEFAULT NOW() + scan_id INTEGER REFERENCES scans(id) + + Table: rules + ──────────── + rule_id VARCHAR(20) PRIMARY KEY e.g. "AZ-STOR-001" + name VARCHAR(200) NOT NULL e.g. "Storage allows public blob access" + severity VARCHAR(10) NOT NULL HIGH | MEDIUM | LOW + category VARCHAR(30) NOT NULL Storage | Network | etc. + description TEXT + remediation TEXT + frameworks JSONB { "CIS": "3.5", "NIST": "AC-3" } + + Table: scans + ──────────── + id SERIAL PRIMARY KEY + scan_id VARCHAR(50) UNIQUE e.g. "scan-001-20260529" + subscription_id VARCHAR(50) + started_at TIMESTAMP DEFAULT NOW() + completed_at TIMESTAMP + total_findings INTEGER DEFAULT 0 + status VARCHAR(20) DEFAULT 'running' running | completed | failed + +──────────────────────────────────────────────────────────────────────────────── + DATA SOURCES β€” what comes from DB vs what is computed +──────────────────────────────────────────────────────────────────────────────── + + FROM DATABASE (straightforward SELECT queries) + ─────────────────────────────────────────────── + GET /api/findings β†’ SELECT * FROM findings JOIN rules ON findings.rule_id = rules.rule_id + GET /api/findings/:id β†’ SELECT * FROM findings JOIN rules WHERE findings.id = :id + GET /api/scans β†’ SELECT * FROM scans ORDER BY started_at DESC + GET /api/scans/:id β†’ SELECT * FROM scans WHERE scan_id = :id + POST /api/scans/trigger β†’ INSERT INTO scans ... (then run scanner async) + + COMPUTED β€” derived from database rows, not stored + ────────────────────────────────────────────────── + GET /api/score + Formula: + total = COUNT(*) FROM findings WHERE status = 'open' + high = COUNT(*) FROM findings WHERE severity = 'HIGH' AND status = 'open' + medium = COUNT(*) FROM findings WHERE severity = 'MEDIUM' AND status = 'open' + score = MAX(0, 100 - (high * 10) - (medium * 3)) + max_score = 100 + Returns: { "score": 72, "max_score": 100 } + + GET /api/compliance/cis (and /nist, /iso27001) + Step 1: Look up which rule_ids map to this framework's controls + (stored in rules.frameworks JSONB column) + Step 2: For each control, check if any open finding has that rule_id + β†’ if yes: status = FAIL + β†’ if no: status = PASS + Step 3: score_percent = (passed / total_controls) * 100 + Note: The mapping between rule_ids and control IDs is in the rules.frameworks + column β€” the scanner team populates this when inserting rules. + + NOT IN DATABASE (no table, no endpoint yet) + ──────────────────────────────────────────── + /api/resources β†’ needs its own `resources` table (future sprint) + /api/drift β†’ needs its own `drift_events` table (future sprint) + /api/prioritization β†’ computed from findings; build after findings is stable + /api/findings/:id/playbook β†’ stored as static content, not in DB (future) + /api/ai/* β†’ calls external AI service, not DB (separate task) + +──────────────────────────────────────────────────────────────────────────────── + SUGGESTED IMPLEMENTATION ORDER +──────────────────────────────────────────────────────────────────────────────── + + 1. api/app.py Flask factory, CORS, JWT middleware, blueprints + 2. api/models/finding.py DatabaseManager + Finding/Rule/Scan models + 3. GET /api/findings Simplest read β€” confirms DB connection works + 4. GET /api/findings/:id Same table, single row + 5. GET /api/scans Scan history + 6. POST /api/scans/trigger Insert scan + trigger async scanner + 7. GET /api/score Computed from findings count + 8. GET /api/compliance/* Computed from findings + rules.frameworks + + Tip: Seed the `rules` table first with all 25 rule definitions from the + mock data (api.findings.json has all rule_ids, names, descriptions). + Without rules in the DB, compliance mapping won't work. + +──────────────────────────────────────────────────────────────────────────────── + SEED DATA FOR RULES TABLE +──────────────────────────────────────────────────────────────────────────────── + + Run this once after creating the schema to populate the rules table. + Copy the rule definitions from the frontend mock data in: + frontend/src/mockData/api.findings.json (has rule_id, description, etc.) + + Minimum set of rules the compliance endpoint needs to work: + + rule_id name severity category CIS NIST ISO + ───────────── ──────────────────────────────── ──────── ───────── ───── ────── ────── + AZ-STOR-001 Storage allows public blob access HIGH Storage 3.5 AC-3 A.10.1.1 + AZ-STOR-002 Storage does not enforce HTTPS HIGH Storage 3.1 SC-8 A.10.1.1 + AZ-NET-001 NSG allows unrestricted SSH HIGH Network 6.2 AC-17 A.13.1.1 + AZ-NET-007 NSG allows unrestricted RDP HIGH Network 6.3 AC-17 A.13.1.1 + AZ-DB-001 SQL database publicly accessible HIGH Database 4.1 SC-7 β€” + AZ-IDN-001 Service Principal over-privileged HIGH Identity 1.20 AC-6 A.9.4.1 + AZ-CMP-001 VM operating system outdated HIGH Compute 7.3 SI-2 A.12.6.1 + AZ-KV-001 Key Vault purge protection missing MEDIUM KeyVault 8.5 β€” A.18.1.3 + AZ-KV-002 Key Vault network ACLs disabled MEDIUM KeyVault 8.1 β€” β€” + + Full list of 25 rules: see frontend/src/mockData/api.findings.json + +================================================================================ + DEMO MODE vs LIVE MODE +================================================================================ + + Demo Mode (default, amber badge in header) + All data comes from mock JSON files in frontend/src/mockData/api.*.json. + No network calls are made. Safe to use without a backend. + + Live Mode (green badge in header) + Calls the real backend at VITE_API_URL. + Requires the backend server to be running on port 5001. + If the backend is unreachable, the app shows an error and falls back + to Demo Mode automatically. + + To switch: + Click the DEMO / LIVE badge in the top-right of the header. + The app will test the connection first before switching to Live. + + Environment variable: + VITE_API_URL=http://localhost:5001 (in frontend/.env.local) + + +================================================================================ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a36934d --- /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 0000000..ea36dd3 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,21 @@ +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: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ed522e5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Open-shield + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e4ff61f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3501 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-icons": "^5.6.0", + "react-router-dom": "^7.16.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "tailwindcss": "^3.4.19", + "vite": "^8.0.12" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "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, + "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, + "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, + "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, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "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, + "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, + "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==", + "dev": true, + "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, + "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==", + "dev": true, + "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==", + "dev": true + }, + "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==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "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.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "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==" + }, + "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==" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "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==" + }, + "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==" + }, + "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==" + }, + "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==", + "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==" + }, + "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==", + "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==", + "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==" + }, + "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==" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "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 + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "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, + "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==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "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, + "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, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true + }, + "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" + } + ], + "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": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "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==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "dev": true, + "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" + } + ], + "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/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==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "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" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "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==", + "dev": true, + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "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 + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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, + "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==", + "dev": true, + "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 + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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, + "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==" + }, + "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 + }, + "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, + "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==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==" + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "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==", + "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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.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", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "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 || ^10.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, + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "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, + "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, + "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, + "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, + "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==" + }, + "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 + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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 + }, + "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 + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "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==", + "dev": true, + "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, + "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==", + "dev": true, + "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, + "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, + "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 + }, + "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==", + "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==", + "dev": true, + "hasInstallScript": true, + "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==", + "dev": true, + "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, + "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==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "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 + }, + "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, + "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, + "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==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "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==", + "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==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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 + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "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 + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "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 + }, + "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 + }, + "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 + }, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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==", + "dev": true, + "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==", + "dev": true + }, + "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, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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 + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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 + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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, + "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, + "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, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "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, + "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==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "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==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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==", + "dev": true, + "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==" + }, + "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, + "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, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "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/react-router": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "dependencies": { + "react-router": "7.16.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "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==" + }, + "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==", + "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==" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==" + }, + "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, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "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, + "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, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "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-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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true + }, + "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, + "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, + "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" + } + ], + "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, + "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==", + "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==", + "dev": true + }, + "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==", + "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.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "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.18", + "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, + "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, + "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 + }, + "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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "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, + "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 0000000..c2bbe89 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "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", + "postcss": "^8.5.15", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-icons": "^5.6.0", + "react-router-dom": "^7.16.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "tailwindcss": "^3.4.19", + "vite": "^8.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /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 0000000..6893eb1 --- /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 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..c53edcf --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { DarkModeProvider } from './contexts/DarkModeContext'; +import { api } from './utils/api'; +import Layout from './components/layout/Layout'; +import Discovery from './pages/Discovery'; +import Prioritization from './pages/Prioritization'; +import Monitoring from './pages/Monitoring'; +import DetailedScan from './pages/DetailedScan'; +import Compliance from './pages/Compliance'; +import Drift from './pages/Drift'; +import AILayer from './pages/AILayer'; + +export default function App() { + useEffect(() => { + // Bootstrap JWT token. + // In production (Vercel): set VITE_JWT_TOKEN to a pre-generated HS256 JWT + // signed with the same JWT_SECRET as the Render backend. + // In local dev: falls back to 'dev-demo-token' (only works when backend + // uses the default insecure JWT_SECRET = 'change-me-in-production'). + if (!api.getToken()) { + api.setToken(import.meta.env.VITE_JWT_TOKEN || 'dev-demo-token'); + } + + // Probe backend health; auto-enable demo mode if unreachable + api.health() + .then((data) => { + if (data?.status === 'ok' && api.isDemoMode()) { + // Backend is online β€” inform the console, but don't override user's choice + console.info('[OpenShield] Backend API is online. Toggle off Demo Mode to use live data.'); + } + }) + .catch(() => { + if (!api.isDemoMode()) { + console.warn('[OpenShield] Backend unreachable β€” switching to Demo Mode.'); + api.setDemoMode(true); + } + }); + }, []); + + return ( + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /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 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/ai/CVEAnalysis.jsx b/frontend/src/components/ai/CVEAnalysis.jsx new file mode 100644 index 0000000..603d6a5 --- /dev/null +++ b/frontend/src/components/ai/CVEAnalysis.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { FiExternalLink, FiCheckCircle, FiAlertTriangle } from 'react-icons/fi'; + +const CVSS_STYLE = (score) => { + if (score >= 9) return { label: 'CRITICAL', cls: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400' }; + if (score >= 7) return { label: 'HIGH', cls: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400' }; + if (score >= 4) return { label: 'MEDIUM', cls: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400' }; + return { label: 'LOW', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' }; +}; + +function SkeletonLine({ w = 'w-full', h = 'h-3' }) { + return
; +} + +export default function CVEAnalysis({ data, loading }) { + if (loading) { + return ( +
+ + {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ); + } + + if (!data?.cves?.length) return null; + + return ( +
+
+

+ CVE Analysis +

+ + {data.total} detected + +
+ +
+ {data.cves.map((cve) => { + const { label, cls } = CVSS_STYLE(cve.cvssScore); + return ( +
+ {/* Top row */} +
+
+ e.stopPropagation()} + > + {cve.id} + + + {cve.cvssScore} {label} + +
+
+ {cve.patchAvailable + ? <> Patch available + : <> No patch + } +
+
+ + {/* Name */} +

{cve.name}

+ + {/* Affected resources */} +
+ + {cve.affectedCount} resource{cve.affectedCount !== 1 ? 's' : ''}: + + {cve.affectedResources.map((r) => ( + + {r} + + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/ai/ChatInput.jsx b/frontend/src/components/ai/ChatInput.jsx new file mode 100644 index 0000000..49ae773 --- /dev/null +++ b/frontend/src/components/ai/ChatInput.jsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { FiSend } from 'react-icons/fi'; + +export default function ChatInput({ onSend, disabled }) { + const [value, setValue] = useState(''); + + const submit = () => { + if (!value.trim() || disabled) return; + onSend(value.trim()); + setValue(''); + }; + + const onKey = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + return ( +
+