mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58b0fa556d | |||
| aa311623fe | |||
| 142b45a387 | |||
| ec102d1569 | |||
| d26f455784 | |||
| 4d5a77a58a | |||
| c183d5e868 | |||
| 74e5118646 | |||
| 48882b553f | |||
| 8acbddd125 | |||
| 786059bfb2 | |||
| 3d4f5e66ab | |||
| a4fc230cf4 | |||
| 1d54244f2b | |||
| ff2bf5b01d | |||
| 703a33108c | |||
| 7c6d658154 | |||
| 21d7d08b4b | |||
| f314725f4d | |||
| 02f43a7ad6 | |||
| 0dd8981ee4 | |||
| 269e51259d | |||
| ba84b23afb | |||
| a9427c8024 | |||
| 10a62a6850 | |||
| b4601abb4e | |||
| 5c981f5683 | |||
| 9922b15391 | |||
| 6e77abea01 | |||
| 4d57f3bef1 |
@@ -0,0 +1,143 @@
|
||||
name: "🔎 New Check Request"
|
||||
description: Request a new Prowler security check
|
||||
title: "[New Check]: "
|
||||
labels: ["feature-request", "status/needs-triage"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: search
|
||||
attributes:
|
||||
label: Existing check search
|
||||
description: Confirm this check does not already exist before opening a new request.
|
||||
options:
|
||||
- label: I have searched existing issues, Prowler Hub, and the public roadmap, and this check does not already exist.
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form to describe the security condition that Prowler should evaluate.
|
||||
|
||||
The most useful inputs for [Prowler Studio](https://github.com/prowler-cloud/prowler-studio) are:
|
||||
- What should be detected
|
||||
- What PASS and FAIL mean
|
||||
- Vendor docs, API references, SDK methods, CLI commands, or reference code
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Provider
|
||||
description: Cloud or platform this check targets.
|
||||
options:
|
||||
- AWS
|
||||
- Azure
|
||||
- GCP
|
||||
- Kubernetes
|
||||
- GitHub
|
||||
- Microsoft 365
|
||||
- OCI
|
||||
- Alibaba Cloud
|
||||
- Cloudflare
|
||||
- MongoDB Atlas
|
||||
- Google Workspace
|
||||
- OpenStack
|
||||
- Vercel
|
||||
- NHN
|
||||
- Other / New provider
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: other_provider_name
|
||||
attributes:
|
||||
label: New provider name
|
||||
description: Only fill this if you selected "Other / New provider" above.
|
||||
placeholder: "NewProviderName"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: service_name
|
||||
attributes:
|
||||
label: Service or product area
|
||||
description: Optional. Main service, product, or feature to audit.
|
||||
placeholder: "s3, bedrock, entra, repository, apiserver"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: suggested_check_name
|
||||
attributes:
|
||||
label: Suggested check name
|
||||
description: Optional. Use `snake_case` following `<service>_<resource>_<best_practice>`, with lowercase letters and underscores only.
|
||||
placeholder: "bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context and goal
|
||||
description: Describe the security problem, why it matters, and what this new check should help detect.
|
||||
placeholder: |-
|
||||
- Security condition to validate:
|
||||
- Why it matters:
|
||||
- Resource, feature, or configuration involved:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Explain what the check should evaluate and what PASS, FAIL, or MANUAL should mean.
|
||||
placeholder: |-
|
||||
- Resource or scope to evaluate:
|
||||
- PASS when:
|
||||
- FAIL when:
|
||||
- MANUAL when (if applicable):
|
||||
- Exclusions, thresholds, or edge cases:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Add vendor docs, API references, SDK methods, CLI commands, endpoint docs, sample payloads, or similar reference material.
|
||||
placeholder: |-
|
||||
- Product or service documentation:
|
||||
- API or SDK reference:
|
||||
- CLI command or endpoint documentation:
|
||||
- Sample payload or response:
|
||||
- Security advisory or benchmark:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Suggested severity
|
||||
description: Your best estimate. Reviewers will confirm during triage.
|
||||
options:
|
||||
- Critical
|
||||
- High
|
||||
- Medium
|
||||
- Low
|
||||
- Informational
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: implementation_notes
|
||||
attributes:
|
||||
label: Additional implementation notes
|
||||
description: Optional. Add permissions, unsupported regions, config knobs, product limitations, or anything else that may affect implementation.
|
||||
placeholder: |-
|
||||
- Required permissions or scopes:
|
||||
- Region, tenant, or subscription limitations:
|
||||
- Configurable behavior or thresholds:
|
||||
- Other constraints:
|
||||
validations:
|
||||
required: false
|
||||
@@ -42,6 +42,8 @@ jobs:
|
||||
fonts.gstatic.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
cdn.playwright.dev:443
|
||||
objects.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -152,6 +154,24 @@ jobs:
|
||||
echo "Only test files changed - running ALL unit tests"
|
||||
pnpm run test:run
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: playwright-cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-chromium-
|
||||
|
||||
- name: Install Playwright Chromium browser
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Run browser tests
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run test:browser
|
||||
|
||||
- name: Build application
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run build
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- New `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,14 +27,28 @@ The most common high level steps to create a new check are:
|
||||
|
||||
### Naming Format for Checks
|
||||
|
||||
Checks must be named following the format: `service_subservice_resource_action`.
|
||||
If you already know the check name when creating a request or implementing a check, use a descriptive identifier with lowercase letters and underscores only.
|
||||
|
||||
Recommended patterns:
|
||||
|
||||
- `<service>_<resource>_<best_practice>`
|
||||
|
||||
The name components are:
|
||||
|
||||
- `service` – The main service being audited (e.g., ec2, entra, iam, etc.)
|
||||
- `subservice` – An individual component or subset of functionality within the service that is being audited. This may correspond to a shortened version of the class attribute accessed within the check. If there is no subservice, just omit.
|
||||
- `resource` – The specific resource type being evaluated (e.g., instance, policy, role, etc.)
|
||||
- `action` – The security aspect or configuration being checked (e.g., public, encrypted, enabled, etc.)
|
||||
- `service` – The main service or product area being audited (e.g., ec2, entra, iam, bedrock).
|
||||
- `resource` – The resource, feature, or configuration being evaluated. It can be a single word or a compound phrase joined with underscores (e.g., instance, policy, guardrail, sensitive_information_filter).
|
||||
- `best_practice` – The expected secure state or best practice being checked (e.g., enabled, encrypted, restricted, configured, not_publicly_accessible).
|
||||
|
||||
Additional guidance:
|
||||
|
||||
- Use underscores only. Do not use hyphens.
|
||||
- Keep the name specific enough to describe the behavior of the check.
|
||||
- The first segment should match the service or product area whenever possible.
|
||||
|
||||
Examples:
|
||||
|
||||
- `s3_bucket_versioning_enabled`
|
||||
- `bedrock_guardrail_sensitive_information_filter_enabled`
|
||||
|
||||
### File Creation
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: 'Prowler Studio'
|
||||
---
|
||||
|
||||
**Prowler Studio is an AI workflow that ensures Claude Code follows Prowler's skills, guardrails, and best practices when creating new security checks.** What lands in the resulting pull request is consistent, tested, and ready for human review — not half-correct boilerplate that needs to be rewritten.
|
||||
|
||||
<Info>
|
||||
**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler App, or Prowler CLI.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
**Preview Feature**: Prowler Studio is under active development and breaking changes are expected. Please report issues or share feedback on [GitHub](https://github.com/prowler-cloud/prowler-studio/issues) or in the [Slack community](https://goto.prowler.com/slack).
|
||||
</Warning>
|
||||
|
||||
<Card title="Prowler Studio Repository" icon="github" href="https://github.com/prowler-cloud/prowler-studio" horizontal>
|
||||
Clone the source code, install Prowler Studio, and explore the agent workflow in detail.
|
||||
</Card>
|
||||
|
||||
## The Problem
|
||||
|
||||
Adding a new check to [Prowler](https://github.com/prowler-cloud/prowler) is more than writing detection logic. A correct check has to:
|
||||
|
||||
- Match Prowler's exact service and check folder structure and naming conventions
|
||||
- Wire up metadata, severity, remediation, tests, and compliance mappings
|
||||
- Mirror the patterns used by the hundreds of existing checks in the same provider
|
||||
- Actually load when Prowler scans for available checks — silent structural mistakes are easy to make
|
||||
|
||||
Asking a general-purpose AI assistant to do this usually means guessing. It misses conventions, skips tests, or invents structure that looks right but does not load. The result is a half-correct PR that needs to be reviewed line by line or rewritten.
|
||||
|
||||
## The Solution
|
||||
|
||||
Prowler Studio enforces the workflow end-to-end. Describe the check once — a markdown ticket, a Jira issue, or a GitHub issue — and the workflow:
|
||||
|
||||
1. **Loads Prowler-specific skills into every agent.** Every step starts with the same context an experienced Prowler engineer would have in mind. See [AI Skills System](/developer-guide/ai-skills) for how skills are structured.
|
||||
2. **Runs specialized agents in sequence.** Implementation → testing → compliance mapping → review → PR creation. Each agent has one job and a tight scope.
|
||||
3. **Verifies as it goes.** The check must load in Prowler. Tests must pass. If something fails, the agent fixes it and re-runs (up to a bounded number of attempts) before moving on.
|
||||
4. **Produces a complete pull request.** Branch, passing check, tests, compliance mappings, and a pull request waiting for human review.
|
||||
|
||||
The result is a consistent starting point, every time, on every supported provider.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install
|
||||
|
||||
Prowler Studio requires [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — see the official [installation guide](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/prowler-cloud/prowler-studio
|
||||
cd prowler-studio
|
||||
uv sync
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Describe the Check
|
||||
|
||||
A ticket is a structured markdown description of the check to create. It is the only input the workflow needs; every agent (implementation, testing, compliance mapping, review, PR creation) uses it as the source of truth, so the more concrete it is, the closer the first PR will land to the desired outcome.
|
||||
|
||||
The ticket can be supplied in three ways:
|
||||
|
||||
- **Local markdown file** → `--ticket path/to/ticket.md`
|
||||
- **Jira issue** → `--jira-url https://...` (uses the issue body)
|
||||
- **GitHub issue** → `--github-url https://...` (uses the issue body)
|
||||
|
||||
The content should follow the **New Check Request** template:
|
||||
|
||||
- The local copy at [`check_ticket_template.md`](https://github.com/prowler-cloud/prowler-studio/blob/main/check_ticket_template.md) covers `--ticket` and Jira tickets.
|
||||
- A prefilled GitHub form is also available: [Create a New Check Request issue](https://github.com/prowler-cloud/prowler/issues/new?template=new-check-request.yml).
|
||||
|
||||
Sections marked *Optional* can be skipped; everything else helps the agents make the right decisions.
|
||||
|
||||
### Run the Workflow
|
||||
|
||||
From a local markdown ticket:
|
||||
|
||||
```bash
|
||||
prowler-studio --ticket check_ticket.md
|
||||
```
|
||||
|
||||
From a Jira ticket:
|
||||
|
||||
```bash
|
||||
prowler-studio --jira-url https://mycompany.atlassian.net/browse/PROJ-123
|
||||
```
|
||||
|
||||
From a GitHub issue:
|
||||
|
||||
```bash
|
||||
prowler-studio --github-url https://github.com/owner/repo/issues/123
|
||||
```
|
||||
|
||||
<Note>
|
||||
Provide exactly one of `--ticket`, `--jira-url`, or `--github-url`.
|
||||
</Note>
|
||||
|
||||
Keep changes local (no push, no pull request):
|
||||
|
||||
```bash
|
||||
prowler-studio -b feat/my-check --ticket check_ticket.md --local
|
||||
```
|
||||
|
||||
### What You Get
|
||||
|
||||
After a successful run the working environment contains:
|
||||
|
||||
- A new branch on a clean Prowler worktree containing the check, metadata, tests, and compliance mappings
|
||||
- A pull request opened against Prowler (skipped with `--local`)
|
||||
- A timestamped log file under `logs/` capturing every step the agents took
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--branch` | `-b` | Branch name (default: `feat/<ticket>-<check_name>` or `feat/<check_name>`) |
|
||||
| `--ticket` | `-t` | Path to a markdown check ticket file |
|
||||
| `--jira-url` | `-j` | Jira ticket URL (e.g., `https://mycompany.atlassian.net/browse/PROJ-123`) |
|
||||
| `--github-url` | `-g` | GitHub issue URL (e.g., `https://github.com/owner/repo/issues/123`) |
|
||||
| `--working-dir` | `-w` | Working directory for the Prowler clone (default: `./working`) |
|
||||
| `--no-worktree` | | Legacy mode — work directly on the main clone instead of using worktrees |
|
||||
| `--cleanup-worktree` | | Remove the worktree after a successful pull request is created |
|
||||
| `--local` | | Keep changes local — skip push and pull request creation |
|
||||
|
||||
## Configuration
|
||||
|
||||
Set these environment variables depending on the input source:
|
||||
|
||||
| Variable | When Needed | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| `GITHUB_TOKEN` | `--github-url` (recommended) | Higher GitHub API rate limits and access to private issues |
|
||||
| `JIRA_SITE_URL` | `--jira-url` | Jira site, e.g. `https://mycompany.atlassian.net` |
|
||||
| `JIRA_EMAIL` | `--jira-url` | Email of the Jira account used to fetch the ticket |
|
||||
| `JIRA_API_TOKEN` | `--jira-url` | API token for the Jira account |
|
||||
+2
-1
@@ -365,7 +365,8 @@
|
||||
"developer-guide/security-compliance-framework",
|
||||
"developer-guide/lighthouse-architecture",
|
||||
"developer-guide/mcp-server",
|
||||
"developer-guide/ai-skills"
|
||||
"developer-guide/ai-skills",
|
||||
"developer-guide/prowler-studio"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -121,8 +121,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.25.1"
|
||||
PROWLER_API_VERSION="5.25.1"
|
||||
PROWLER_UI_VERSION="5.25.2"
|
||||
PROWLER_API_VERSION="5.25.2"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -159,6 +159,40 @@ When these environment variables are set, the API will use them directly instead
|
||||
A fix addressing this permission issue is being evaluated in [PR #9953](https://github.com/prowler-cloud/prowler/pull/9953).
|
||||
</Note>
|
||||
|
||||
### Scan Stuck in Executing State After Worker Crash
|
||||
|
||||
When running Prowler App via Docker Compose, a scan may remain indefinitely in the `executing` state if the worker process crashes (for example, due to an Out of Memory condition) before it can update the scan status. Since it is not currently possible to cancel a scan in `executing` state through the UI, the workaround is to manually update the scan record in the database.
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
The Celery worker process terminates unexpectedly (OOM, node failure, etc.) before transitioning the scan state to `completed` or `failed`. The scan record remains in `executing` with no active process to advance it.
|
||||
|
||||
**Solution:**
|
||||
|
||||
Connect to the database using the `prowler_admin` user. Due to Row-Level Security (RLS), the default database user cannot see scan records — you must use `prowler_admin`:
|
||||
|
||||
```bash
|
||||
psql -U prowler_admin -d prowler_db
|
||||
```
|
||||
|
||||
Identify the stuck scan by filtering for scans in `executing` state:
|
||||
|
||||
```sql
|
||||
SELECT id, name, state, started_at FROM scans WHERE state = 'executing';
|
||||
```
|
||||
|
||||
Update the scan state to `failed` using the scan ID:
|
||||
|
||||
```sql
|
||||
UPDATE scans SET state = 'failed' WHERE id = '<scan-id>';
|
||||
```
|
||||
|
||||
After this change, the scan will appear as failed in the UI and you can launch a new scan.
|
||||
|
||||
<Note>
|
||||
A feature to cancel executing scans directly from the UI is being tracked in [GitHub Issue #6893](https://github.com/prowler-cloud/prowler/issues/6893).
|
||||
</Note>
|
||||
|
||||
### SAML/OAuth ACS URL Incorrect When Running Behind a Proxy or Load Balancer
|
||||
|
||||
See [GitHub Issue #9724](https://github.com/prowler-cloud/prowler/issues/9724) for more details.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
title: 'Prowler Check Kreator'
|
||||
---
|
||||
|
||||
<Note>
|
||||
Currently, this tool is only available for creating checks for the AWS provider.
|
||||
|
||||
</Note>
|
||||
<Note>
|
||||
If you are looking for a way to create new checks for all the supported providers, you can use [Prowler Studio](https://github.com/prowler-cloud/prowler-studio), it is an AI-powered toolkit for generating and managing security checks for Prowler (better version of the Check Kreator).
|
||||
|
||||
</Note>
|
||||
## Introduction
|
||||
|
||||
**Prowler Check Kreator** is a utility designed to streamline the creation of new checks for Prowler. This tool generates all necessary files required to add a new check to the Prowler repository. Specifically, it creates:
|
||||
|
||||
- A dedicated folder for the check.
|
||||
- The main check script.
|
||||
- A metadata file with essential details.
|
||||
- A folder and file structure for testing the check.
|
||||
|
||||
## Usage
|
||||
|
||||
To use the tool, execute the main script with the following command:
|
||||
|
||||
```bash
|
||||
python util/prowler_check_kreator/prowler_check_kreator.py <prowler_provider> <check_name>
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `<prowler_provider>`: Currently only AWS is supported.
|
||||
- `<check_name>`: The name you wish to assign to the new check.
|
||||
|
||||
## AI integration
|
||||
|
||||
This tool optionally integrates AI to assist in generating the check code and metadata file content. When AI assistance is chosen, the tool uses [Gemini](https://gemini.google.com/) to produce preliminary code and metadata.
|
||||
|
||||
<Note>
|
||||
For this feature to work, you must have the library `google-generativeai` installed in your Python environment.
|
||||
|
||||
</Note>
|
||||
<Warning>
|
||||
AI-generated code and metadata might contain errors or require adjustments to align with specific Prowler requirements. Carefully review all AI-generated content before committing.
|
||||
|
||||
</Warning>
|
||||
To enable AI assistance, simply confirm when prompted by the tool. Additionally, ensure that the `GEMINI_API_KEY` environment variable is set with a valid Gemini API key. For instructions on obtaining your API key, refer to the [Gemini documentation](https://ai.google.dev/gemini-api/docs/api-key).
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
title: 'Prowler Check Kreator'
|
||||
---
|
||||
|
||||
<Note>
|
||||
Currently, this tool is only available for creating checks for the AWS provider.
|
||||
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
If you are looking for a way to create new checks for all the supported providers, you can use [Prowler Studio](https://github.com/prowler-cloud/prowler-studio), it is an AI-powered toolkit for generating and managing security checks for Prowler (better version of the Check Kreator).
|
||||
|
||||
</Note>
|
||||
|
||||
## Introduction
|
||||
|
||||
**Prowler Check Kreator** is a utility designed to streamline the creation of new checks for Prowler. This tool generates all necessary files required to add a new check to the Prowler repository. Specifically, it creates:
|
||||
|
||||
- A dedicated folder for the check.
|
||||
- The main check script.
|
||||
- A metadata file with essential details.
|
||||
- A folder and file structure for testing the check.
|
||||
|
||||
## Usage
|
||||
|
||||
To use the tool, execute the main script with the following command:
|
||||
|
||||
```bash
|
||||
python util/prowler_check_kreator/prowler_check_kreator.py <prowler_provider> <check_name>
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `<prowler_provider>`: Currently only AWS is supported.
|
||||
- `<check_name>`: The name you wish to assign to the new check.
|
||||
|
||||
## AI integration
|
||||
|
||||
This tool optionally integrates AI to assist in generating the check code and metadata file content. When AI assistance is chosen, the tool uses [Gemini](https://gemini.google.com/) to produce preliminary code and metadata.
|
||||
|
||||
<Note>
|
||||
For this feature to work, you must have the library `google-generativeai` installed in your Python environment.
|
||||
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
AI-generated code and metadata might contain errors or require adjustments to align with specific Prowler requirements. Carefully review all AI-generated content before committing.
|
||||
|
||||
</Warning>
|
||||
|
||||
To enable AI assistance, simply confirm when prompted by the tool. Additionally, ensure that the `GEMINI_API_KEY` environment variable is set with a valid Gemini API key. For instructions on obtaining your API key, refer to the [Gemini documentation](https://ai.google.dev/gemini-api/docs/api-key).
|
||||
+12
-2
@@ -14,9 +14,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `route53_dangling_ip_subdomain_takeover` now also flags `CNAME` records pointing to S3 website endpoints whose buckets are missing from the account [(#10920)](https://github.com/prowler-cloud/prowler/pull/10920)
|
||||
- Azure Network Watcher flow log checks now require workspace-backed Traffic Analytics for `network_flow_log_captured_sent` and align metadata with VNet-compatible flow log guidance [(#10645)](https://github.com/prowler-cloud/prowler/pull/10645)
|
||||
- Azure compliance entries for legacy Network Watcher flow log controls now use retirement-aware guidance and point new deployments to VNet flow logs
|
||||
- Azure compliance entries for legacy Network Watcher flow log controls now use retirement-aware guidance and point new deployments to VNet flow logs [(#10937)](https://github.com/prowler-cloud/prowler/pull/10937)
|
||||
- AWS CodeBuild service now batches `BatchGetProjects` and `BatchGetBuilds` calls per region (up to 100 items per call) to reduce API call volume and prevent throttling-induced false positives in `codebuild_project_not_publicly_accessible` [(#10639)](https://github.com/prowler-cloud/prowler/pull/10639)
|
||||
- `display_compliance_table` dispatch switched from substring `in` checks to `startswith` to prevent false matches between similarly named frameworks (e.g. `cisa` vs `cis`) [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
|
||||
@@ -32,6 +31,17 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [5.25.2] (Prowler v5.25.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `route53_dangling_ip_subdomain_takeover` now also flags `CNAME` records pointing to S3 website endpoints whose buckets are missing from the account [(#10920)](https://github.com/prowler-cloud/prowler/pull/10920)
|
||||
- Duplicate Kubernetes RBAC findings when the same User or Group subject appeared in multiple ClusterRoleBindings [(#10242)](https://github.com/prowler-cloud/prowler/pull/10242)
|
||||
- Match K8s RBAC rules by `apiGroup` [(#10969)](https://github.com/prowler-cloud/prowler/pull/10969)
|
||||
- Return a compact actor name from CloudTrail `userIdentity` events [(#10986)](https://github.com/prowler-cloud/prowler/pull/10986)
|
||||
|
||||
---
|
||||
|
||||
## [5.25.1] (Prowler v5.25.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -221,27 +221,12 @@ class CloudTrailTimeline(TimelineService):
|
||||
|
||||
@staticmethod
|
||||
def _extract_actor(user_identity: Dict[str, Any]) -> str:
|
||||
"""Extract a human-readable actor name from CloudTrail userIdentity."""
|
||||
# Try ARN first - most reliable
|
||||
"""Return a compact actor name from CloudTrail userIdentity.
|
||||
|
||||
For ARNs, returns the resource portion (everything after the last
|
||||
`:`) — e.g. `user/alice`, `assumed-role/MyRole/session-name`,
|
||||
`root`. The full ARN is preserved separately in `actor_uid`.
|
||||
"""
|
||||
if arn := user_identity.get("arn"):
|
||||
if "/" in arn:
|
||||
parts = arn.split("/")
|
||||
# For assumed-role, return the role name (second-to-last part)
|
||||
if "assumed-role" in arn and len(parts) >= 2:
|
||||
return parts[-2]
|
||||
return parts[-1]
|
||||
return arn.split(":")[-1]
|
||||
|
||||
# Fall back to userName
|
||||
if username := user_identity.get("userName"):
|
||||
return username
|
||||
|
||||
# Fall back to principalId
|
||||
if principal_id := user_identity.get("principalId"):
|
||||
return principal_id
|
||||
|
||||
# For service-invoked actions
|
||||
if invoking_service := user_identity.get("invokedBy"):
|
||||
return invoking_service
|
||||
|
||||
return "Unknown"
|
||||
return arn.rsplit(":", 1)[-1]
|
||||
return user_identity.get("invokedBy") or "Unknown"
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
def is_rule_allowing_permissions(rules, resources, verbs):
|
||||
def is_rule_allowing_permissions(rules, resources, verbs, api_groups=("",)):
|
||||
"""
|
||||
Check Kubernetes role permissions.
|
||||
Check whether any RBAC rule grants the specified verbs on the specified
|
||||
resources within the specified API groups.
|
||||
|
||||
This function takes in Kubernetes role rules, resources, and verbs,
|
||||
and checks if any of the rules grant permissions on the specified
|
||||
resources with the specified verbs.
|
||||
A rule matches when its `apiGroups` includes any of `api_groups` (or "*"),
|
||||
its `resources` includes any of `resources` (or "*"), and its `verbs`
|
||||
includes any of `verbs` (or "*").
|
||||
|
||||
Args:
|
||||
rules (List[Rule]): The list of Kubernetes role rules.
|
||||
resources (List[str]): The list of resources to check permissions for.
|
||||
verbs (List[str]): The list of verbs to check permissions for.
|
||||
rules (List[Rule]): RBAC rules from a Role or ClusterRole.
|
||||
resources (List[str]): Resources (or sub-resources) to check.
|
||||
verbs (List[str]): Verbs to check.
|
||||
api_groups (Iterable[str]): API groups the resources live in. Defaults
|
||||
to ("",), the core API group, which matches the most common case.
|
||||
Pass an explicit value for resources outside the core group, e.g.
|
||||
("admissionregistration.k8s.io",) for webhook configurations.
|
||||
|
||||
Returns:
|
||||
bool: True if any of the rules grant permissions, False otherwise.
|
||||
bool: True if any rule grants the permission, False otherwise.
|
||||
"""
|
||||
if rules:
|
||||
# Iterate through each rule in the list of rules
|
||||
for rule in rules:
|
||||
# Ensure apiGroups are relevant ("" or "v1" for secrets)
|
||||
if rule.apiGroups and all(api not in ["", "v1"] for api in rule.apiGroups):
|
||||
continue # Skip rules with unrelated apiGroups
|
||||
# Check if the rule has resources, verbs, and matches any of the specified resources and verbs
|
||||
if (
|
||||
rule.resources
|
||||
and (
|
||||
any(resource in rule.resources for resource in resources)
|
||||
or "*" in rule.resources
|
||||
)
|
||||
and rule.verbs
|
||||
and (any(verb in rule.verbs for verb in verbs) or "*" in rule.verbs)
|
||||
):
|
||||
# If the rule matches, return True
|
||||
return True
|
||||
# If no rule matches, return False
|
||||
if not rules:
|
||||
return False
|
||||
for rule in rules:
|
||||
rule_api_groups = rule.apiGroups or [""]
|
||||
if not (
|
||||
any(g in rule_api_groups for g in api_groups) or "*" in rule_api_groups
|
||||
):
|
||||
continue
|
||||
if (
|
||||
rule.resources
|
||||
and (any(r in rule.resources for r in resources) or "*" in rule.resources)
|
||||
and rule.verbs
|
||||
and (any(v in rule.verbs for v in verbs) or "*" in rule.verbs)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
+27
-16
@@ -6,29 +6,40 @@ from prowler.providers.kubernetes.services.rbac.rbac_client import rbac_client
|
||||
|
||||
verbs = ["update", "patch"]
|
||||
resources = ["certificatesigningrequests/approval"]
|
||||
api_groups = ["certificates.k8s.io"]
|
||||
|
||||
|
||||
class rbac_minimize_csr_approval_access(Check):
|
||||
def execute(self) -> Check_Report_Kubernetes:
|
||||
findings = []
|
||||
# Collect unique subjects and the ClusterRole names bound to them
|
||||
subjects_bound_roles = {}
|
||||
for crb in rbac_client.cluster_role_bindings.values():
|
||||
for subject in crb.subjects:
|
||||
# CIS benchmarks scope these checks to human identities only
|
||||
if subject.kind in ["User", "Group"]:
|
||||
report = Check_Report_Kubernetes(
|
||||
metadata=self.metadata(), resource=subject
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to update the CSR approval sub-resource."
|
||||
for cr in rbac_client.cluster_roles.values():
|
||||
if cr.metadata.name == crb.roleRef.name:
|
||||
if is_rule_allowing_permissions(
|
||||
cr.rules,
|
||||
resources,
|
||||
verbs,
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to update the CSR approval sub-resource."
|
||||
break
|
||||
findings.append(report)
|
||||
key = (subject.kind, subject.name, subject.namespace)
|
||||
if key not in subjects_bound_roles:
|
||||
subjects_bound_roles[key] = (subject, set())
|
||||
subjects_bound_roles[key][1].add(crb.roleRef.name)
|
||||
|
||||
cluster_roles_by_name = {
|
||||
cr.metadata.name: cr for cr in rbac_client.cluster_roles.values()
|
||||
}
|
||||
for _, (subject, role_names) in subjects_bound_roles.items():
|
||||
report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject)
|
||||
report.resource_name = f"{subject.kind}:{subject.name}"
|
||||
report.resource_id = f"{subject.kind}/{subject.name}"
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to update the CSR approval sub-resource."
|
||||
for role_name in role_names:
|
||||
cr = cluster_roles_by_name.get(role_name)
|
||||
if cr and is_rule_allowing_permissions(
|
||||
cr.rules, resources, verbs, api_groups
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to update the CSR approval sub-resource."
|
||||
break
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
+24
-12
@@ -11,20 +11,32 @@ resources = ["nodes/proxy"]
|
||||
class rbac_minimize_node_proxy_subresource_access(Check):
|
||||
def execute(self) -> Check_Report_Kubernetes:
|
||||
findings = []
|
||||
# Collect unique subjects and the ClusterRole names bound to them
|
||||
subjects_bound_roles = {}
|
||||
for crb in rbac_client.cluster_role_bindings.values():
|
||||
for subject in crb.subjects:
|
||||
# CIS benchmarks scope these checks to human identities only
|
||||
if subject.kind in ["User", "Group"]:
|
||||
report = Check_Report_Kubernetes(
|
||||
metadata=self.metadata(), resource=subject
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to the node proxy sub-resource."
|
||||
for cr in rbac_client.cluster_roles.values():
|
||||
if cr.metadata.name == crb.roleRef.name:
|
||||
if is_rule_allowing_permissions(cr.rules, resources, verbs):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to the node proxy sub-resource."
|
||||
break
|
||||
findings.append(report)
|
||||
key = (subject.kind, subject.name, subject.namespace)
|
||||
if key not in subjects_bound_roles:
|
||||
subjects_bound_roles[key] = (subject, set())
|
||||
subjects_bound_roles[key][1].add(crb.roleRef.name)
|
||||
|
||||
cluster_roles_by_name = {
|
||||
cr.metadata.name: cr for cr in rbac_client.cluster_roles.values()
|
||||
}
|
||||
for _, (subject, role_names) in subjects_bound_roles.items():
|
||||
report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject)
|
||||
report.resource_name = f"{subject.kind}:{subject.name}"
|
||||
report.resource_id = f"{subject.kind}/{subject.name}"
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to the node proxy sub-resource."
|
||||
for role_name in role_names:
|
||||
cr = cluster_roles_by_name.get(role_name)
|
||||
if cr and is_rule_allowing_permissions(cr.rules, resources, verbs):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to the node proxy sub-resource."
|
||||
break
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
+24
-13
@@ -11,21 +11,32 @@ resources = ["persistentvolumes"]
|
||||
class rbac_minimize_pv_creation_access(Check):
|
||||
def execute(self) -> Check_Report_Kubernetes:
|
||||
findings = []
|
||||
# Check each ClusterRoleBinding for access to create PersistentVolumes
|
||||
# Collect unique subjects and the ClusterRole names bound to them
|
||||
subjects_bound_roles = {}
|
||||
for crb in rbac_client.cluster_role_bindings.values():
|
||||
for subject in crb.subjects:
|
||||
# CIS benchmarks scope these checks to human identities only
|
||||
if subject.kind in ["User", "Group"]:
|
||||
report = Check_Report_Kubernetes(
|
||||
metadata=self.metadata(), resource=subject
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to create PersistentVolumes."
|
||||
for cr in rbac_client.cluster_roles.values():
|
||||
if cr.metadata.name == crb.roleRef.name:
|
||||
if is_rule_allowing_permissions(cr.rules, resources, verbs):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to create PersistentVolumes."
|
||||
break
|
||||
findings.append(report)
|
||||
key = (subject.kind, subject.name, subject.namespace)
|
||||
if key not in subjects_bound_roles:
|
||||
subjects_bound_roles[key] = (subject, set())
|
||||
subjects_bound_roles[key][1].add(crb.roleRef.name)
|
||||
|
||||
cluster_roles_by_name = {
|
||||
cr.metadata.name: cr for cr in rbac_client.cluster_roles.values()
|
||||
}
|
||||
for _, (subject, role_names) in subjects_bound_roles.items():
|
||||
report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject)
|
||||
report.resource_name = f"{subject.kind}:{subject.name}"
|
||||
report.resource_id = f"{subject.kind}/{subject.name}"
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to create PersistentVolumes."
|
||||
for role_name in role_names:
|
||||
cr = cluster_roles_by_name.get(role_name)
|
||||
if cr and is_rule_allowing_permissions(cr.rules, resources, verbs):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to create PersistentVolumes."
|
||||
break
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
+24
-12
@@ -11,20 +11,32 @@ resources = ["serviceaccounts/token"]
|
||||
class rbac_minimize_service_account_token_creation(Check):
|
||||
def execute(self) -> Check_Report_Kubernetes:
|
||||
findings = []
|
||||
# Collect unique subjects and the ClusterRole names bound to them
|
||||
subjects_bound_roles = {}
|
||||
for crb in rbac_client.cluster_role_bindings.values():
|
||||
for subject in crb.subjects:
|
||||
# CIS benchmarks scope these checks to human identities only
|
||||
if subject.kind in ["User", "Group"]:
|
||||
report = Check_Report_Kubernetes(
|
||||
metadata=self.metadata(), resource=subject
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to create service account tokens."
|
||||
for cr in rbac_client.cluster_roles.values():
|
||||
if cr.metadata.name == crb.roleRef.name:
|
||||
if is_rule_allowing_permissions(cr.rules, resources, verbs):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to create service account tokens."
|
||||
break
|
||||
findings.append(report)
|
||||
key = (subject.kind, subject.name, subject.namespace)
|
||||
if key not in subjects_bound_roles:
|
||||
subjects_bound_roles[key] = (subject, set())
|
||||
subjects_bound_roles[key][1].add(crb.roleRef.name)
|
||||
|
||||
cluster_roles_by_name = {
|
||||
cr.metadata.name: cr for cr in rbac_client.cluster_roles.values()
|
||||
}
|
||||
for _, (subject, role_names) in subjects_bound_roles.items():
|
||||
report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject)
|
||||
report.resource_name = f"{subject.kind}:{subject.name}"
|
||||
report.resource_id = f"{subject.kind}/{subject.name}"
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to create service account tokens."
|
||||
for role_name in role_names:
|
||||
cr = cluster_roles_by_name.get(role_name)
|
||||
if cr and is_rule_allowing_permissions(cr.rules, resources, verbs):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to create service account tokens."
|
||||
break
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
+27
-16
@@ -9,29 +9,40 @@ resources = [
|
||||
"mutatingwebhookconfigurations",
|
||||
]
|
||||
verbs = ["create", "update", "delete"]
|
||||
api_groups = ["admissionregistration.k8s.io"]
|
||||
|
||||
|
||||
class rbac_minimize_webhook_config_access(Check):
|
||||
def execute(self) -> Check_Report_Kubernetes:
|
||||
findings = []
|
||||
# Collect unique subjects and the ClusterRole names bound to them
|
||||
subjects_bound_roles = {}
|
||||
for crb in rbac_client.cluster_role_bindings.values():
|
||||
for subject in crb.subjects:
|
||||
# CIS benchmarks scope these checks to human identities only
|
||||
if subject.kind in ["User", "Group"]:
|
||||
report = Check_Report_Kubernetes(
|
||||
metadata=self.metadata(), resource=subject
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to create, update, or delete webhook configurations."
|
||||
for cr in rbac_client.cluster_roles.values():
|
||||
if cr.metadata.name == crb.roleRef.name:
|
||||
if is_rule_allowing_permissions(
|
||||
cr.rules,
|
||||
resources,
|
||||
verbs,
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to create, update, or delete webhook configurations."
|
||||
break
|
||||
findings.append(report)
|
||||
key = (subject.kind, subject.name, subject.namespace)
|
||||
if key not in subjects_bound_roles:
|
||||
subjects_bound_roles[key] = (subject, set())
|
||||
subjects_bound_roles[key][1].add(crb.roleRef.name)
|
||||
|
||||
cluster_roles_by_name = {
|
||||
cr.metadata.name: cr for cr in rbac_client.cluster_roles.values()
|
||||
}
|
||||
for _, (subject, role_names) in subjects_bound_roles.items():
|
||||
report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject)
|
||||
report.resource_name = f"{subject.kind}:{subject.name}"
|
||||
report.resource_id = f"{subject.kind}/{subject.name}"
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User or group '{subject.name}' does not have access to create, update, or delete webhook configurations."
|
||||
for role_name in role_names:
|
||||
cr = cluster_roles_by_name.get(role_name)
|
||||
if cr and is_rule_allowing_permissions(
|
||||
cr.rules, resources, verbs, api_groups
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User or group '{subject.name}' has access to create, update, or delete webhook configurations."
|
||||
break
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
@@ -100,7 +100,7 @@ class TestCloudTrailTimeline:
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["event_name"] == "RunInstances"
|
||||
assert result[0]["actor"] == "admin"
|
||||
assert result[0]["actor"] == "user/admin"
|
||||
assert result[0]["source_ip_address"] == "203.0.113.1"
|
||||
|
||||
def test_get_resource_timeline_with_resource_uid(
|
||||
@@ -304,14 +304,28 @@ class TestExtractActor:
|
||||
"arn": "arn:aws:iam::123456789012:user/alice",
|
||||
"userName": "alice",
|
||||
}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "alice"
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "user/alice"
|
||||
|
||||
def test_extract_actor_assumed_role(self):
|
||||
user_identity = {
|
||||
"type": "AssumedRole",
|
||||
"arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name",
|
||||
}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "MyRole"
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(user_identity)
|
||||
== "assumed-role/MyRole/session-name"
|
||||
)
|
||||
|
||||
def test_extract_actor_assumed_role_sso(self):
|
||||
"""SSO sessions store the user identity in the session name."""
|
||||
user_identity = {
|
||||
"type": "AssumedRole",
|
||||
"arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com",
|
||||
}
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(user_identity)
|
||||
== "assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com"
|
||||
)
|
||||
|
||||
def test_extract_actor_root(self):
|
||||
user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"}
|
||||
@@ -327,21 +341,33 @@ class TestExtractActor:
|
||||
== "elasticloadbalancing.amazonaws.com"
|
||||
)
|
||||
|
||||
def test_extract_actor_fallback_to_principal_id(self):
|
||||
user_identity = {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(user_identity) == "AROAEXAMPLEID:session"
|
||||
)
|
||||
|
||||
def test_extract_actor_unknown(self):
|
||||
assert CloudTrailTimeline._extract_actor({}) == "Unknown"
|
||||
|
||||
def test_extract_actor_username_only_returns_unknown(self):
|
||||
"""When userIdentity carries only userName/principalId (no arn or
|
||||
invokedBy), we deliberately return "Unknown" — we rely on the ARN
|
||||
from the upstream service for the actor."""
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor({"type": "IAMUser", "userName": "alice"})
|
||||
== "Unknown"
|
||||
)
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(
|
||||
{"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
|
||||
)
|
||||
== "Unknown"
|
||||
)
|
||||
|
||||
def test_extract_actor_federated_user(self):
|
||||
user_identity = {
|
||||
"type": "FederatedUser",
|
||||
"arn": "arn:aws:sts::123456789012:federated-user/developer",
|
||||
}
|
||||
assert CloudTrailTimeline._extract_actor(user_identity) == "developer"
|
||||
assert (
|
||||
CloudTrailTimeline._extract_actor(user_identity)
|
||||
== "federated-user/developer"
|
||||
)
|
||||
|
||||
|
||||
class TestParseEvent:
|
||||
@@ -380,7 +406,7 @@ class TestParseEvent:
|
||||
assert result is not None
|
||||
assert result["event_name"] == "RunInstances"
|
||||
assert result["event_source"] == "ec2.amazonaws.com"
|
||||
assert result["actor"] == "admin"
|
||||
assert result["actor"] == "user/admin"
|
||||
assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin"
|
||||
assert result["actor_type"] == "IAMUser"
|
||||
|
||||
@@ -424,7 +450,10 @@ class TestParseEvent:
|
||||
"EventName": "RunInstances",
|
||||
"EventSource": "ec2.amazonaws.com",
|
||||
"CloudTrailEvent": {
|
||||
"userIdentity": {"type": "IAMUser", "userName": "admin"},
|
||||
"userIdentity": {
|
||||
"type": "IAMUser",
|
||||
"arn": "arn:aws:iam::123456789012:user/admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
timeline = CloudTrailTimeline(session=mock_session)
|
||||
@@ -432,7 +461,7 @@ class TestParseEvent:
|
||||
|
||||
assert result is not None
|
||||
assert result["event_name"] == "RunInstances"
|
||||
assert result["actor"] == "admin"
|
||||
assert result["actor"] == "user/admin"
|
||||
|
||||
def test_parse_event_missing_event_id(self, mock_session):
|
||||
"""Test parsing event without EventId returns None (event_id is required)."""
|
||||
@@ -506,7 +535,7 @@ class TestParseEvent:
|
||||
|
||||
assert result is not None
|
||||
assert result["event_name"] == "RunInstances"
|
||||
assert result["actor"] == "admin"
|
||||
assert result["actor"] == "user/admin"
|
||||
# actor_type should be None when not present in userIdentity
|
||||
assert result["actor_type"] is None
|
||||
|
||||
|
||||
@@ -6,90 +6,92 @@ from prowler.providers.kubernetes.services.rbac.rbac_service import Rule
|
||||
|
||||
class TestCheckRolePermissions:
|
||||
def test_is_rule_allowing_permissions(self):
|
||||
# Define some sample rules, resources, and verbs for testing
|
||||
rules = [
|
||||
# Rule 1: Allows 'get' and 'list' on 'pods' and 'services'
|
||||
Rule(resources=["pods", "services"], verbs=["get", "list"]),
|
||||
# Rule 2: Allows 'create' and 'delete' on 'deployments'
|
||||
Rule(resources=["deployments"], verbs=["create", "delete"]),
|
||||
]
|
||||
resources = ["pods", "deployments"]
|
||||
verbs = ["get", "create"]
|
||||
|
||||
assert is_rule_allowing_permissions(rules, resources, verbs)
|
||||
assert is_rule_allowing_permissions(
|
||||
rules, ["pods", "deployments"], ["get", "create"]
|
||||
)
|
||||
|
||||
def test_no_permissions(self):
|
||||
# Test when there are no rules
|
||||
rules = []
|
||||
resources = ["pods", "deployments"]
|
||||
verbs = ["get", "create"]
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
assert not is_rule_allowing_permissions([], ["pods"], ["get"])
|
||||
|
||||
def test_no_matching_rules(self):
|
||||
# Test when there are rules, but none match the specified resources and verbs
|
||||
rules = [
|
||||
Rule(resources=["services"], verbs=["get", "list"]),
|
||||
Rule(resources=["pods"], verbs=["create", "delete"]),
|
||||
]
|
||||
resources = ["deployments", "configmaps"]
|
||||
verbs = ["get", "create"]
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
assert not is_rule_allowing_permissions(
|
||||
rules, ["deployments", "configmaps"], ["get", "create"]
|
||||
)
|
||||
|
||||
def test_empty_rules(self):
|
||||
# Test when the rules list is empty
|
||||
rules = []
|
||||
resources = ["pods", "deployments"]
|
||||
verbs = ["get", "create"]
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
assert not is_rule_allowing_permissions([], ["pods"], ["get"])
|
||||
|
||||
def test_empty_resources_and_verbs(self):
|
||||
# Test when resources and verbs are empty lists
|
||||
rules = [
|
||||
Rule(resources=["pods"], verbs=["get"]),
|
||||
Rule(resources=["services"], verbs=["list"]),
|
||||
]
|
||||
resources = []
|
||||
verbs = []
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
rules = [Rule(resources=["pods"], verbs=["get"])]
|
||||
assert not is_rule_allowing_permissions(rules, [], [])
|
||||
|
||||
def test_matching_rule_with_empty_resources_or_verbs(self):
|
||||
# Test when a rule matches, but either resources or verbs are empty
|
||||
rules = [Rule(resources=["pods"], verbs=["get"])]
|
||||
assert not is_rule_allowing_permissions(rules, [], ["get"])
|
||||
assert not is_rule_allowing_permissions(rules, ["pods"], [])
|
||||
|
||||
def test_rule_with_non_matching_api_group(self):
|
||||
rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=["apps"])]
|
||||
assert not is_rule_allowing_permissions(rules, ["pods"], ["get"])
|
||||
|
||||
def test_rule_with_matching_api_group(self):
|
||||
rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=[""])]
|
||||
assert is_rule_allowing_permissions(rules, ["pods"], ["get"])
|
||||
|
||||
def test_default_api_group_is_core(self):
|
||||
rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=None)]
|
||||
assert is_rule_allowing_permissions(rules, ["pods"], ["get"])
|
||||
|
||||
def test_rule_with_empty_api_groups_does_not_match_non_core_request(self):
|
||||
rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=None)]
|
||||
assert not is_rule_allowing_permissions(
|
||||
rules, ["pods"], ["get"], ["admissionregistration.k8s.io"]
|
||||
)
|
||||
|
||||
def test_non_core_rule_does_not_match_without_api_groups_argument(self):
|
||||
rules = [
|
||||
Rule(resources=["pods"], verbs=["get"]),
|
||||
Rule(resources=["services"], verbs=["list"]),
|
||||
Rule(
|
||||
resources=["validatingwebhookconfigurations"],
|
||||
verbs=["create"],
|
||||
apiGroups=["admissionregistration.k8s.io"],
|
||||
)
|
||||
]
|
||||
resources = []
|
||||
verbs = ["get"]
|
||||
assert not is_rule_allowing_permissions(
|
||||
rules, ["validatingwebhookconfigurations"], ["create"]
|
||||
)
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
|
||||
resources = ["pods"]
|
||||
verbs = []
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
|
||||
def test_rule_with_ignored_api_groups(self):
|
||||
# Test when a rule has apiGroups that are not relevant
|
||||
def test_explicit_non_core_api_group(self):
|
||||
rules = [
|
||||
Rule(resources=["pods"], verbs=["get"], apiGroups=["test"]),
|
||||
Rule(resources=["services"], verbs=["list"], apiGroups=["test2"]),
|
||||
Rule(
|
||||
resources=["validatingwebhookconfigurations"],
|
||||
verbs=["create"],
|
||||
apiGroups=["admissionregistration.k8s.io"],
|
||||
)
|
||||
]
|
||||
resources = ["pods"]
|
||||
verbs = ["get"]
|
||||
assert is_rule_allowing_permissions(
|
||||
rules,
|
||||
["validatingwebhookconfigurations"],
|
||||
["create"],
|
||||
["admissionregistration.k8s.io"],
|
||||
)
|
||||
|
||||
assert not is_rule_allowing_permissions(rules, resources, verbs)
|
||||
def test_rule_with_wildcard_api_group(self):
|
||||
rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=["*"])]
|
||||
assert is_rule_allowing_permissions(rules, ["pods"], ["get"])
|
||||
assert is_rule_allowing_permissions(rules, ["pods"], ["get"], ["apps"])
|
||||
|
||||
def test_rule_with_relevant_api_groups(self):
|
||||
# Test when a rule has apiGroups that are relevant
|
||||
rules = [
|
||||
Rule(resources=["pods"], verbs=["get"], apiGroups=["", "v1"]),
|
||||
Rule(resources=["services"], verbs=["list"], apiGroups=["test2"]),
|
||||
]
|
||||
resources = ["pods"]
|
||||
verbs = ["get"]
|
||||
def test_rule_with_wildcard_resources(self):
|
||||
rules = [Rule(resources=["*"], verbs=["get"], apiGroups=[""])]
|
||||
assert is_rule_allowing_permissions(rules, ["pods"], ["get"])
|
||||
|
||||
assert is_rule_allowing_permissions(rules, resources, verbs)
|
||||
def test_rule_with_wildcard_verbs(self):
|
||||
rules = [Rule(resources=["pods"], verbs=["*"], apiGroups=[""])]
|
||||
assert is_rule_allowing_permissions(rules, ["pods"], ["get"])
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
__screenshots__/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -28,6 +29,9 @@ yarn-error.log*
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
+13
-1
@@ -2,7 +2,19 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.25.2] (Prowler UNRELEASED)
|
||||
## Unreleased
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Browser test mode using Vitest with the Playwright provider, with initial coverage of the Attack Paths page and a new `pnpm test:browser` script wired into CI
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths graph: extract shared primitives across `FindingNode`, `ResourceNode`, and `InternetNode` (hidden handles, label truncation, fill/border resolution) without forcing a generic node renderer [(#10705)](https://github.com/prowler-cloud/prowler/pull/10705)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.2] (Prowler v5.25.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
import type { PageFixture } from "@/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures";
|
||||
import type {
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
AttackPathQueryResult,
|
||||
AttackPathScan,
|
||||
AttackPathScansResponse,
|
||||
QueryResultAttributes,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||
|
||||
type JsonApiErrorBody = {
|
||||
errors: Array<{ detail: string; status: string }>;
|
||||
};
|
||||
|
||||
const toScansApiResponse = (
|
||||
scans: AttackPathScan[],
|
||||
): AttackPathScansResponse => ({
|
||||
data: scans,
|
||||
links: {
|
||||
first: `${API}/attack-paths-scans?page=1`,
|
||||
last: `${API}/attack-paths-scans?page=1`,
|
||||
next: null,
|
||||
prev: null,
|
||||
},
|
||||
});
|
||||
|
||||
const toQueriesApiResponse = (
|
||||
queries: AttackPathQuery[],
|
||||
): AttackPathQueriesResponse => ({
|
||||
data: queries,
|
||||
});
|
||||
|
||||
const toQueryResultApiResponse = (
|
||||
attrs: QueryResultAttributes,
|
||||
queryId: string,
|
||||
): AttackPathQueryResult => ({
|
||||
data: {
|
||||
type: "attack-paths-query-run-requests",
|
||||
id: queryId,
|
||||
attributes: attrs,
|
||||
},
|
||||
});
|
||||
|
||||
const toErrorBody = (detail: string, status: number): JsonApiErrorBody => ({
|
||||
errors: [{ detail, status: String(status) }],
|
||||
});
|
||||
|
||||
export const handlersForFixture = (fx: PageFixture) => [
|
||||
http.get(`${API}/attack-paths-scans`, () =>
|
||||
HttpResponse.json<AttackPathScansResponse>(toScansApiResponse(fx.scans)),
|
||||
),
|
||||
|
||||
http.get<{ scanId: string }>(
|
||||
`${API}/attack-paths-scans/:scanId/queries`,
|
||||
() =>
|
||||
HttpResponse.json<AttackPathQueriesResponse>(
|
||||
toQueriesApiResponse(fx.queries),
|
||||
),
|
||||
),
|
||||
|
||||
http.post<{ scanId: string }>(
|
||||
`${API}/attack-paths-scans/:scanId/queries/run`,
|
||||
() => {
|
||||
if (fx.queryError) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody(fx.queryError.error, fx.queryError.status),
|
||||
{ status: fx.queryError.status },
|
||||
);
|
||||
}
|
||||
if (!fx.queryResult) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody("No data found", 404),
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json<AttackPathQueryResult>(
|
||||
toQueryResultApiResponse(fx.queryResult, fx.queryId),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
http.post<{ scanId: string }>(
|
||||
`${API}/attack-paths-scans/:scanId/queries/custom`,
|
||||
() => {
|
||||
if (fx.queryError) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody(fx.queryError.error, fx.queryError.status),
|
||||
{ status: fx.queryError.status },
|
||||
);
|
||||
}
|
||||
if (!fx.queryResult) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody("No data found", 404),
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json<AttackPathQueryResult>(
|
||||
toQueryResultApiResponse(fx.queryResult, fx.queryId),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HttpHandler } from "msw";
|
||||
|
||||
/**
|
||||
* Static handlers shared by every browser test — registered as defaults on
|
||||
* the worker. Use this list for endpoints whose response doesn't change
|
||||
* across tests (e.g. `/users/me`, `/tenants/current`, health checks).
|
||||
*
|
||||
* Per-domain dynamic handlers that depend on fixture data live in their own
|
||||
* files alongside this index (e.g. `./attack-paths.ts`) and are imported
|
||||
* directly by the tests that need them, then wired via
|
||||
* `worker.use(...handlersForFixture(fx))`.
|
||||
*/
|
||||
export const handlers: HttpHandler[] = [];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ComponentType, PropsWithChildren, ReactElement } from "react";
|
||||
import { render as vitestRender } from "vitest-browser-react";
|
||||
|
||||
const TestProviders = ({ children }: PropsWithChildren) => <>{children}</>;
|
||||
|
||||
type RenderOptions = Parameters<typeof vitestRender>[1];
|
||||
|
||||
export function render(ui: ReactElement, options?: RenderOptions) {
|
||||
const userWrapper = options?.wrapper as
|
||||
| ComponentType<PropsWithChildren>
|
||||
| undefined;
|
||||
|
||||
const Wrapper = userWrapper
|
||||
? ({ children }: PropsWithChildren) => {
|
||||
const Inner = userWrapper;
|
||||
return (
|
||||
<TestProviders>
|
||||
<Inner>{children}</Inner>
|
||||
</TestProviders>
|
||||
);
|
||||
}
|
||||
: TestProviders;
|
||||
|
||||
return vitestRender(ui, { ...options, wrapper: Wrapper });
|
||||
}
|
||||
@@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData(
|
||||
// Populate findings and resources based on HAS_FINDING edges
|
||||
edges.forEach((edge) => {
|
||||
if (edge.type === "HAS_FINDING") {
|
||||
const sourceId =
|
||||
typeof edge.source === "string"
|
||||
? edge.source
|
||||
: (edge.source as { id?: string })?.id;
|
||||
const targetId =
|
||||
typeof edge.target === "string"
|
||||
? edge.target
|
||||
: (edge.target as { id?: string })?.id;
|
||||
// Add finding to source node (resource -> finding)
|
||||
const sourceNode = normalizedNodes.find((n) => n.id === edge.source);
|
||||
if (sourceNode) {
|
||||
sourceNode.findings.push(edge.target);
|
||||
}
|
||||
|
||||
if (sourceId && targetId) {
|
||||
// Add finding to source node (resource -> finding)
|
||||
const sourceNode = normalizedNodes.find((n) => n.id === sourceId);
|
||||
if (sourceNode) {
|
||||
sourceNode.findings.push(targetId);
|
||||
}
|
||||
|
||||
// Add resource to target node (finding <- resource)
|
||||
const targetNode = normalizedNodes.find((n) => n.id === targetId);
|
||||
if (targetNode) {
|
||||
targetNode.resources.push(sourceId);
|
||||
}
|
||||
// Add resource to target node (finding <- resource)
|
||||
const targetNode = normalizedNodes.find((n) => n.id === edge.target);
|
||||
if (targetNode) {
|
||||
targetNode.resources.push(edge.source);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+516
-1110
File diff suppressed because it is too large
Load Diff
+43
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GraphControls } from "./graph-controls";
|
||||
|
||||
const baseProps = {
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onFitToScreen: vi.fn(),
|
||||
};
|
||||
|
||||
describe("GraphControls", () => {
|
||||
it("disables the export button and surfaces the unavailable message when no onExport is provided", () => {
|
||||
render(<GraphControls {...baseProps} />);
|
||||
|
||||
const exportButton = screen.getByRole("button", {
|
||||
name: /export available soon/i,
|
||||
});
|
||||
|
||||
expect(exportButton).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^export graph$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables the export button and invokes the callback when onExport is provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExport = vi.fn();
|
||||
|
||||
render(<GraphControls {...baseProps} onExport={onExport} />);
|
||||
|
||||
const exportButton = screen.getByRole("button", {
|
||||
name: /^export graph$/i,
|
||||
});
|
||||
|
||||
expect(exportButton).toBeEnabled();
|
||||
|
||||
await user.click(exportButton);
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+9
-2
@@ -14,7 +14,7 @@ interface GraphControlsProps {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitToScreen: () => void;
|
||||
onExport: () => void;
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +38,7 @@ export const GraphControls = ({
|
||||
size="sm"
|
||||
onClick={onZoomIn}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn size={18} />
|
||||
</Button>
|
||||
@@ -52,6 +53,7 @@ export const GraphControls = ({
|
||||
size="sm"
|
||||
onClick={onZoomOut}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut size={18} />
|
||||
</Button>
|
||||
@@ -66,6 +68,7 @@ export const GraphControls = ({
|
||||
size="sm"
|
||||
onClick={onFitToScreen}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Fit graph to view"
|
||||
>
|
||||
<Minimize2 size={18} />
|
||||
</Button>
|
||||
@@ -79,12 +82,16 @@ export const GraphControls = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onExport}
|
||||
disabled={!onExport}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label={onExport ? "Export graph" : "Export available soon"}
|
||||
>
|
||||
<Download size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export graph</TooltipContent>
|
||||
<TooltipContent>
|
||||
{onExport ? "Export graph" : "Export available soon"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { AttackPathGraphRef } from "./attack-path-graph";
|
||||
export type { GraphHandle } from "./attack-path-graph";
|
||||
export { AttackPathGraph } from "./attack-path-graph";
|
||||
export { GraphControls } from "./graph-controls";
|
||||
export { GraphLegend } from "./graph-legend";
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors, truncateLabel } from "../../../_lib";
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface FindingNodeData {
|
||||
graphNode: GraphNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HEXAGON_WIDTH = 200;
|
||||
const HEXAGON_HEIGHT = 55;
|
||||
const TITLE_MAX_CHARS = 24;
|
||||
|
||||
export const FindingNode = ({ data, selected }: NodeProps) => {
|
||||
const { graphNode } = data as FindingNodeData;
|
||||
const { fillColor, borderColor } = resolveNodeColors({
|
||||
labels: graphNode.labels,
|
||||
properties: graphNode.properties,
|
||||
selected,
|
||||
});
|
||||
|
||||
const title = String(
|
||||
graphNode.properties?.check_title ||
|
||||
graphNode.properties?.name ||
|
||||
graphNode.properties?.id ||
|
||||
"Finding",
|
||||
);
|
||||
const displayTitle = truncateLabel(title, TITLE_MAX_CHARS);
|
||||
|
||||
// Hexagon SVG path
|
||||
const w = HEXAGON_WIDTH;
|
||||
const h = HEXAGON_HEIGHT;
|
||||
const sideInset = w * 0.15;
|
||||
const hexPath = `
|
||||
M ${sideInset} 0
|
||||
L ${w - sideInset} 0
|
||||
L ${w} ${h / 2}
|
||||
L ${w - sideInset} ${h}
|
||||
L ${sideInset} ${h}
|
||||
L 0 ${h / 2}
|
||||
Z
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HiddenHandles />
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
className="overflow-visible"
|
||||
style={{ filter: selected ? undefined : "url(#glow)" }}
|
||||
>
|
||||
<path
|
||||
d={hexPath}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
className={selected ? "selected-node" : undefined}
|
||||
/>
|
||||
<text
|
||||
x={w / 2}
|
||||
y={h / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{displayTitle}
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
|
||||
export const HiddenHandles = () => (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="invisible" />
|
||||
<Handle type="source" position={Position.Right} className="invisible" />
|
||||
</>
|
||||
);
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors } from "../../../_lib";
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface InternetNodeData {
|
||||
graphNode: GraphNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const RADIUS = 40; // NODE_HEIGHT * 0.8
|
||||
const DIAMETER = RADIUS * 2;
|
||||
|
||||
export const InternetNode = ({ data, selected }: NodeProps) => {
|
||||
const { graphNode } = data as InternetNodeData;
|
||||
const { fillColor, borderColor } = resolveNodeColors({
|
||||
labels: graphNode.labels,
|
||||
properties: graphNode.properties,
|
||||
selected,
|
||||
});
|
||||
const strokeWidth = selected ? 4 : 1.5;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HiddenHandles />
|
||||
<svg width={DIAMETER} height={DIAMETER} className="overflow-visible">
|
||||
{/* Main circle */}
|
||||
<circle
|
||||
cx={RADIUS}
|
||||
cy={RADIUS}
|
||||
r={RADIUS}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={strokeWidth}
|
||||
className={selected ? "selected-node" : undefined}
|
||||
/>
|
||||
{/* Horizontal ellipse (equator) */}
|
||||
<ellipse
|
||||
cx={RADIUS}
|
||||
cy={RADIUS}
|
||||
rx={RADIUS}
|
||||
ry={RADIUS * 0.35}
|
||||
fill="none"
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
{/* Vertical ellipse (meridian) */}
|
||||
<ellipse
|
||||
cx={RADIUS}
|
||||
cy={RADIUS}
|
||||
rx={RADIUS * 0.35}
|
||||
ry={RADIUS}
|
||||
fill="none"
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
{/* Label */}
|
||||
<text
|
||||
x={RADIUS}
|
||||
y={RADIUS}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
Internet
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors, truncateLabel } from "../../../_lib";
|
||||
import { formatNodeLabel } from "../../../_lib/format";
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface ResourceNodeData {
|
||||
graphNode: GraphNode;
|
||||
hasFindings?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 50;
|
||||
const NODE_RADIUS = 25;
|
||||
const NAME_MAX_CHARS = 22;
|
||||
|
||||
export const ResourceNode = ({ data, selected }: NodeProps) => {
|
||||
const { graphNode, hasFindings } = data as ResourceNodeData;
|
||||
const { fillColor, borderColor } = resolveNodeColors({
|
||||
labels: graphNode.labels,
|
||||
properties: graphNode.properties,
|
||||
selected,
|
||||
hasFindings,
|
||||
});
|
||||
const strokeWidth = selected ? 4 : hasFindings ? 2.5 : 1.5;
|
||||
|
||||
const name = String(
|
||||
graphNode.properties?.name ||
|
||||
graphNode.properties?.id ||
|
||||
(graphNode.labels.length > 0
|
||||
? formatNodeLabel(graphNode.labels[0])
|
||||
: "Unknown"),
|
||||
);
|
||||
const displayName = truncateLabel(name, NAME_MAX_CHARS);
|
||||
|
||||
const typeLabel =
|
||||
graphNode.labels.length > 0 ? formatNodeLabel(graphNode.labels[0]) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<HiddenHandles />
|
||||
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={NODE_RADIUS}
|
||||
ry={NODE_RADIUS}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={strokeWidth}
|
||||
className={selected ? "selected-node" : undefined}
|
||||
/>
|
||||
<text
|
||||
x={NODE_WIDTH / 2}
|
||||
y={NODE_HEIGHT / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<tspan
|
||||
x={NODE_WIDTH / 2}
|
||||
dy="-0.3em"
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
>
|
||||
{displayName}
|
||||
</tspan>
|
||||
{typeLabel && (
|
||||
<tspan
|
||||
x={NODE_WIDTH / 2}
|
||||
dy="1.3em"
|
||||
fontSize="9px"
|
||||
fill="rgba(255,255,255,0.8)"
|
||||
>
|
||||
{typeLabel}
|
||||
</tspan>
|
||||
)}
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel";
|
||||
export { NodeOverview } from "./node-overview";
|
||||
export { NodeRelationships } from "./node-relationships";
|
||||
export { NodeRemediation } from "./node-remediation";
|
||||
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GraphEdge } from "@/types/attack-paths";
|
||||
|
||||
interface NodeRelationshipsProps {
|
||||
incomingEdges: GraphEdge[];
|
||||
outgoingEdges: GraphEdge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format edge type to human-readable label
|
||||
* e.g., "HAS_FINDING" -> "Has Finding"
|
||||
*/
|
||||
function formatEdgeType(edgeType: string): string {
|
||||
return edgeType
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
interface EdgeItemProps {
|
||||
edge: GraphEdge;
|
||||
isOutgoing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable edge item component
|
||||
*/
|
||||
function EdgeItem({ edge, isOutgoing }: EdgeItemProps) {
|
||||
const targetId =
|
||||
typeof edge.target === "string" ? edge.target : String(edge.target);
|
||||
const sourceId =
|
||||
typeof edge.source === "string" ? edge.source : String(edge.source);
|
||||
const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={edge.id}
|
||||
className="border-border-neutral-tertiary dark:border-border-neutral-tertiary flex items-center justify-between rounded border p-2"
|
||||
>
|
||||
<code className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
||||
{displayId}
|
||||
</code>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs font-medium",
|
||||
isOutgoing
|
||||
? "bg-bg-data-info text-text-neutral-primary dark:text-text-neutral-primary"
|
||||
: "bg-bg-pass-primary text-text-neutral-primary dark:text-text-neutral-primary",
|
||||
)}
|
||||
>
|
||||
{formatEdgeType(edge.type)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Node relationships section showing incoming and outgoing edges
|
||||
*/
|
||||
export const NodeRelationships = ({
|
||||
incomingEdges,
|
||||
outgoingEdges,
|
||||
}: NodeRelationshipsProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Outgoing Relationships */}
|
||||
<div>
|
||||
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
|
||||
Outgoing Relationships ({outgoingEdges.length})
|
||||
</h4>
|
||||
{outgoingEdges.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{outgoingEdges.map((edge) => (
|
||||
<EdgeItem key={edge.id} edge={edge} isOutgoing />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
|
||||
No outgoing relationships
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Incoming Relationships */}
|
||||
<div className="border-border-neutral-tertiary dark:border-border-neutral-tertiary border-t pt-6">
|
||||
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
|
||||
Incoming Relationships ({incomingEdges.length})
|
||||
</h4>
|
||||
{incomingEdges.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{incomingEdges.map((edge) => (
|
||||
<EdgeItem key={edge.id} edge={edge} isOutgoing={false} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
|
||||
No incoming relationships
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,11 @@ interface FilteredViewState {
|
||||
isFilteredView: boolean;
|
||||
filteredNodeId: string | null;
|
||||
fullData: AttackPathGraphData | null; // Original data before filtering
|
||||
// Tier 1 expansion state: which resource nodes have their findings revealed.
|
||||
// Lives in the store (not local component state) so it survives the data
|
||||
// swaps that happen when entering/exiting filtered view. Reset only on
|
||||
// fresh data loads (new query / scan) — see `setGraphData`.
|
||||
expandedResources: Set<string>;
|
||||
}
|
||||
|
||||
interface GraphStore extends GraphState, FilteredViewState {
|
||||
@@ -21,14 +26,13 @@ interface GraphStore extends GraphState, FilteredViewState {
|
||||
setSelectedNodeId: (nodeId: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setZoom: (zoomLevel: number) => void;
|
||||
setPan: (panX: number, panY: number) => void;
|
||||
setFilteredView: (
|
||||
isFiltered: boolean,
|
||||
nodeId: string | null,
|
||||
filteredData: AttackPathGraphData | null,
|
||||
fullData: AttackPathGraphData | null,
|
||||
) => void;
|
||||
toggleExpandedResource: (resourceId: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -37,15 +41,13 @@ const initialState: GraphState & FilteredViewState = {
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
zoomLevel: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
isFilteredView: false,
|
||||
filteredNodeId: null,
|
||||
fullData: null,
|
||||
expandedResources: new Set(),
|
||||
};
|
||||
|
||||
const useGraphStore = create<GraphStore>((set) => ({
|
||||
export const useGraphStore = create<GraphStore>((set) => ({
|
||||
...initialState,
|
||||
setGraphData: (data) =>
|
||||
set({
|
||||
@@ -54,12 +56,12 @@ const useGraphStore = create<GraphStore>((set) => ({
|
||||
error: null,
|
||||
isFilteredView: false,
|
||||
filteredNodeId: null,
|
||||
// Fresh data → drop any stale expansion from the previous graph.
|
||||
expandedResources: new Set(),
|
||||
}),
|
||||
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setZoom: (zoomLevel) => set({ zoomLevel }),
|
||||
setPan: (panX, panY) => set({ panX, panY }),
|
||||
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
|
||||
set({
|
||||
isFilteredView: isFiltered,
|
||||
@@ -68,6 +70,16 @@ const useGraphStore = create<GraphStore>((set) => ({
|
||||
fullData,
|
||||
selectedNodeId: nodeId,
|
||||
}),
|
||||
toggleExpandedResource: (resourceId) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedResources);
|
||||
if (next.has(resourceId)) {
|
||||
next.delete(resourceId);
|
||||
} else {
|
||||
next.add(resourceId);
|
||||
}
|
||||
return { expandedResources: next };
|
||||
}),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -106,11 +118,6 @@ export const useGraphState = () => {
|
||||
store.setError(error);
|
||||
};
|
||||
|
||||
const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => {
|
||||
store.setZoom(zoomLevel);
|
||||
store.setPan(panX, panY);
|
||||
};
|
||||
|
||||
const resetGraph = () => {
|
||||
store.reset();
|
||||
};
|
||||
@@ -162,18 +169,16 @@ export const useGraphState = () => {
|
||||
selectedNode: getSelectedNode(),
|
||||
loading: store.loading,
|
||||
error: store.error,
|
||||
zoomLevel: store.zoomLevel,
|
||||
panX: store.panX,
|
||||
panY: store.panY,
|
||||
isFilteredView: store.isFilteredView,
|
||||
filteredNodeId: store.filteredNodeId,
|
||||
filteredNode: getFilteredNode(),
|
||||
expandedResources: store.expandedResources,
|
||||
toggleExpandedResource: store.toggleExpandedResource,
|
||||
updateGraphData,
|
||||
selectNode,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
setError,
|
||||
updateZoomAndPan,
|
||||
resetGraph,
|
||||
clearGraph,
|
||||
enterFilteredView,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Rect } from "@xyflow/react";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { exportGraphAsJSON, exportGraphAsPNG } from "./export";
|
||||
|
||||
vi.mock("modern-screenshot", () => ({
|
||||
domToPng: vi.fn(),
|
||||
}));
|
||||
|
||||
const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 };
|
||||
|
||||
const buildContainerWithViewport = () => {
|
||||
const container = document.createElement("div");
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "react-flow__viewport";
|
||||
container.appendChild(viewport);
|
||||
return container;
|
||||
};
|
||||
|
||||
describe("exportGraphAsPNG", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(domToPng).mockReset();
|
||||
});
|
||||
|
||||
it("throws when the container is not mounted", async () => {
|
||||
await expect(exportGraphAsPNG(null, bounds)).rejects.toThrow(
|
||||
"Graph container not mounted",
|
||||
);
|
||||
expect(domToPng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the React Flow viewport is missing inside the container", async () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
await expect(exportGraphAsPNG(container, bounds)).rejects.toThrow(
|
||||
"React Flow viewport not found in container",
|
||||
);
|
||||
expect(domToPng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when bounds are null (no nodes to export)", async () => {
|
||||
const container = buildContainerWithViewport();
|
||||
|
||||
await expect(exportGraphAsPNG(container, null)).rejects.toThrow(
|
||||
"No nodes to export",
|
||||
);
|
||||
expect(domToPng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-throws a generic export error when domToPng rejects", async () => {
|
||||
const container = buildContainerWithViewport();
|
||||
vi.mocked(domToPng).mockRejectedValueOnce(new Error("rasterizer boom"));
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(exportGraphAsPNG(container, bounds)).rejects.toThrow(
|
||||
"Failed to export graph",
|
||||
);
|
||||
expect(domToPng).toHaveBeenCalledOnce();
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportGraphAsJSON", () => {
|
||||
it("re-throws a generic export error when serialization fails", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => exportGraphAsJSON(circular)).toThrow("Failed to export graph");
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Export utilities for attack path graphs
|
||||
* Handles exporting graph visualization to various formats
|
||||
* React Flow renders HTML, so PNG export uses modern-screenshot + RF viewport math
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper function to download a blob as a file
|
||||
* @param blob The blob to download
|
||||
* @param filename The name of the file
|
||||
*/
|
||||
import { getViewportForBounds, type Rect } from "@xyflow/react";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -19,106 +17,73 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export graph as SVG image
|
||||
* @param svgElement The SVG element to export
|
||||
* @param filename The name of the file to download
|
||||
*/
|
||||
export const exportGraphAsSVG = (
|
||||
svgElement: SVGSVGElement | null,
|
||||
filename: string = "attack-path-graph.svg",
|
||||
) => {
|
||||
if (!svgElement) return;
|
||||
|
||||
try {
|
||||
// Clone the SVG element to avoid modifying the original
|
||||
const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// Find the main container group (first g element with transform)
|
||||
const containerGroup = clonedSvg.querySelector("g");
|
||||
if (!containerGroup) {
|
||||
throw new Error("Could not find graph container");
|
||||
}
|
||||
|
||||
// Get the bounding box of the actual graph content
|
||||
// We need to get it from the original SVG since cloned elements don't have computed geometry
|
||||
const originalContainer = svgElement.querySelector("g");
|
||||
if (!originalContainer) {
|
||||
throw new Error("Could not find original graph container");
|
||||
}
|
||||
|
||||
const bbox = originalContainer.getBBox();
|
||||
|
||||
// Add padding around the content
|
||||
const padding = 50;
|
||||
const contentWidth = bbox.width + padding * 2;
|
||||
const contentHeight = bbox.height + padding * 2;
|
||||
|
||||
// Set the SVG dimensions to fit the content
|
||||
clonedSvg.setAttribute("width", `${contentWidth}`);
|
||||
clonedSvg.setAttribute("height", `${contentHeight}`);
|
||||
clonedSvg.setAttribute(
|
||||
"viewBox",
|
||||
`${bbox.x - padding} ${bbox.y - padding} ${contentWidth} ${contentHeight}`,
|
||||
);
|
||||
|
||||
// Remove the zoom transform from the container - the viewBox now handles positioning
|
||||
containerGroup.removeAttribute("transform");
|
||||
|
||||
// Add white background for better visibility
|
||||
const bgRect = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"rect",
|
||||
);
|
||||
bgRect.setAttribute("x", `${bbox.x - padding}`);
|
||||
bgRect.setAttribute("y", `${bbox.y - padding}`);
|
||||
bgRect.setAttribute("width", `${contentWidth}`);
|
||||
bgRect.setAttribute("height", `${contentHeight}`);
|
||||
bgRect.setAttribute("fill", "#1c1917"); // Dark background matching the app
|
||||
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
const blob = new Blob([svgData], { type: "image/svg+xml" });
|
||||
downloadBlob(blob, filename);
|
||||
} catch (error) {
|
||||
console.error("Failed to export graph as SVG:", error);
|
||||
throw new Error("Failed to export graph");
|
||||
}
|
||||
const downloadDataUrl = (dataUrl: string, filename: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Export target dimensions — fixed so bounds math is deterministic across zoom levels
|
||||
const EXPORT_IMAGE_WIDTH = 1920;
|
||||
const EXPORT_IMAGE_HEIGHT = 1080;
|
||||
const EXPORT_MIN_ZOOM = 0.2;
|
||||
const EXPORT_MAX_ZOOM = 2;
|
||||
const EXPORT_PADDING = 0.1;
|
||||
const EXPORT_BACKGROUND = "#1c1917";
|
||||
|
||||
/**
|
||||
* Export graph as PNG image
|
||||
* @param svgElement The SVG element to export
|
||||
* @param filename The name of the file to download
|
||||
* Export graph as PNG via modern-screenshot.
|
||||
*
|
||||
* Receives pre-computed node bounds (use `GraphHandle.getNodesBounds()` so the
|
||||
* React Flow instance's `nodeLookup` is honored for sub-flows). Then uses
|
||||
* `getViewportForBounds()` to produce a viewport transform that fits all nodes
|
||||
* inside the export canvas regardless of the user's current zoom/pan, and
|
||||
* applies it to `.react-flow__viewport` before rasterizing.
|
||||
*/
|
||||
export const exportGraphAsPNG = async (
|
||||
svgElement: SVGSVGElement | null,
|
||||
containerElement: HTMLDivElement | null,
|
||||
bounds: Rect | null,
|
||||
filename: string = "attack-path-graph.png",
|
||||
) => {
|
||||
if (!svgElement) return;
|
||||
if (!containerElement) {
|
||||
throw new Error("Graph container not mounted");
|
||||
}
|
||||
|
||||
const viewportElement = containerElement.querySelector<HTMLElement>(
|
||||
".react-flow__viewport",
|
||||
);
|
||||
if (!viewportElement) {
|
||||
throw new Error("React Flow viewport not found in container");
|
||||
}
|
||||
|
||||
if (!bounds) {
|
||||
throw new Error("No nodes to export");
|
||||
}
|
||||
|
||||
const viewport = getViewportForBounds(
|
||||
bounds,
|
||||
EXPORT_IMAGE_WIDTH,
|
||||
EXPORT_IMAGE_HEIGHT,
|
||||
EXPORT_MIN_ZOOM,
|
||||
EXPORT_MAX_ZOOM,
|
||||
EXPORT_PADDING,
|
||||
);
|
||||
|
||||
try {
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
|
||||
|
||||
if (!ctx) throw new Error("Could not get canvas context");
|
||||
|
||||
const svg = new Image();
|
||||
svg.onload = () => {
|
||||
canvas.width = svg.width;
|
||||
canvas.height = svg.height;
|
||||
ctx.drawImage(svg, 0, 0);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
});
|
||||
};
|
||||
svg.onerror = () => {
|
||||
throw new Error("Failed to load SVG for PNG conversion");
|
||||
};
|
||||
svg.src = `data:image/svg+xml;base64,${btoa(svgData)}`;
|
||||
const dataUrl = await domToPng(viewportElement, {
|
||||
backgroundColor: EXPORT_BACKGROUND,
|
||||
width: EXPORT_IMAGE_WIDTH,
|
||||
height: EXPORT_IMAGE_HEIGHT,
|
||||
style: {
|
||||
width: `${EXPORT_IMAGE_WIDTH}px`,
|
||||
height: `${EXPORT_IMAGE_HEIGHT}px`,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||
},
|
||||
});
|
||||
downloadDataUrl(dataUrl, filename);
|
||||
} catch (error) {
|
||||
console.error("Failed to export graph as PNG:", error);
|
||||
throw new Error("Failed to export graph");
|
||||
@@ -126,9 +91,7 @@ export const exportGraphAsPNG = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Export graph data as JSON
|
||||
* @param graphData The graph data to export
|
||||
* @param filename The name of the file to download
|
||||
* Export graph data as JSON (format-agnostic — does not depend on DOM rendering).
|
||||
*/
|
||||
export const exportGraphAsJSON = (
|
||||
graphData: Record<string, unknown>,
|
||||
|
||||
@@ -23,3 +23,7 @@ export function formatNodeLabel(label: string): string {
|
||||
export function formatNodeLabels(labels: string[]): string {
|
||||
return labels.map(formatNodeLabel).join(", ");
|
||||
}
|
||||
|
||||
export function truncateLabel(text: string, maxChars: number): string {
|
||||
return text.length > maxChars ? `${text.substring(0, maxChars)}...` : text;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,37 @@ export const getNodeBorderColor = (
|
||||
return GRAPH_NODE_BORDER_COLORS.default;
|
||||
};
|
||||
|
||||
interface ResolveNodeColorsParams {
|
||||
labels: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
selected?: boolean;
|
||||
hasFindings?: boolean;
|
||||
}
|
||||
|
||||
interface NodeColorResult {
|
||||
fillColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fill and border colors for a graph node, layering selection and
|
||||
* finding-alert state on top of the label/severity defaults.
|
||||
*/
|
||||
export const resolveNodeColors = ({
|
||||
labels,
|
||||
properties,
|
||||
selected,
|
||||
hasFindings,
|
||||
}: ResolveNodeColorsParams): NodeColorResult => {
|
||||
const fillColor = getNodeColor(labels, properties);
|
||||
const borderColor = hasFindings
|
||||
? GRAPH_ALERT_BORDER_COLOR
|
||||
: selected
|
||||
? GRAPH_EDGE_HIGHLIGHT_COLOR
|
||||
: getNodeBorderColor(labels, properties);
|
||||
return { fillColor, borderColor };
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a background color is light (for determining text color)
|
||||
*/
|
||||
|
||||
@@ -4,23 +4,6 @@
|
||||
|
||||
import type { AttackPathGraphData } from "@/types/attack-paths";
|
||||
|
||||
/**
|
||||
* Type for edge node reference - can be a string ID or an object with id property
|
||||
* Note: We use `object` to match GraphEdge type from attack-paths.ts
|
||||
*/
|
||||
export type EdgeNodeRef = string | object;
|
||||
|
||||
/**
|
||||
* Helper to get edge source/target ID from string or object
|
||||
*/
|
||||
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
|
||||
if (typeof nodeRef === "string") {
|
||||
return nodeRef;
|
||||
}
|
||||
// Edge node references are objects with an id property
|
||||
return (nodeRef as { id: string }).id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute a filtered subgraph containing only the path through the target node.
|
||||
* This follows the directed graph structure of attack paths:
|
||||
@@ -44,10 +27,8 @@ export const computeFilteredSubgraph = (
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
forwardEdges.get(sourceId)?.add(targetId);
|
||||
backwardEdges.get(targetId)?.add(sourceId);
|
||||
forwardEdges.get(edge.source)?.add(edge.target);
|
||||
backwardEdges.get(edge.target)?.add(edge.source);
|
||||
});
|
||||
|
||||
const visibleNodeIds = new Set<string>();
|
||||
@@ -84,35 +65,30 @@ export const computeFilteredSubgraph = (
|
||||
traverseDownstream(targetNodeId);
|
||||
|
||||
// Also include findings directly connected to the selected node
|
||||
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
const sourceNode = nodes.find((n) => n.id === sourceId);
|
||||
const targetNode = nodes.find((n) => n.id === targetId);
|
||||
|
||||
const sourceIsFinding = sourceNode?.labels.some((l) =>
|
||||
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
);
|
||||
const targetIsFinding = targetNode?.labels.some((l) =>
|
||||
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
|
||||
l.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
// Include findings connected to the selected node
|
||||
if (sourceId === targetNodeId && targetIsFinding) {
|
||||
visibleNodeIds.add(targetId);
|
||||
if (edge.source === targetNodeId && targetIsFinding) {
|
||||
visibleNodeIds.add(edge.target);
|
||||
}
|
||||
if (targetId === targetNodeId && sourceIsFinding) {
|
||||
visibleNodeIds.add(sourceId);
|
||||
if (edge.target === targetNodeId && sourceIsFinding) {
|
||||
visibleNodeIds.add(edge.source);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter nodes and edges to only include visible ones
|
||||
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
|
||||
const filteredEdges = edges.filter((edge) => {
|
||||
const sourceId = getEdgeNodeId(edge.source);
|
||||
const targetId = getEdgeNodeId(edge.target);
|
||||
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
|
||||
});
|
||||
const filteredEdges = edges.filter(
|
||||
(edge) =>
|
||||
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: filteredNodes,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export {
|
||||
exportGraphAsJSON,
|
||||
exportGraphAsPNG,
|
||||
exportGraphAsSVG,
|
||||
} from "./export";
|
||||
export { formatNodeLabel, formatNodeLabels } from "./format";
|
||||
export { exportGraphAsJSON, exportGraphAsPNG } from "./export";
|
||||
export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format";
|
||||
export {
|
||||
getNodeBorderColor,
|
||||
getNodeColor,
|
||||
@@ -14,10 +10,13 @@ export {
|
||||
GRAPH_NODE_BORDER_COLORS,
|
||||
GRAPH_NODE_COLORS,
|
||||
GRAPH_SELECTION_COLOR,
|
||||
resolveNodeColors,
|
||||
} from "./graph-colors";
|
||||
export { computeFilteredSubgraph, getPathEdges } from "./graph-utils";
|
||||
export { layoutWithDagre } from "./layout";
|
||||
export {
|
||||
computeFilteredSubgraph,
|
||||
type EdgeNodeRef,
|
||||
getEdgeNodeId,
|
||||
getPathEdges,
|
||||
} from "./graph-utils";
|
||||
NODE_CATEGORY,
|
||||
type NodeCategory,
|
||||
type NodeVisual,
|
||||
resolveNodeVisual,
|
||||
} from "./node-visuals";
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { layoutWithDagre } from "./layout";
|
||||
|
||||
const findingNode: GraphNode = {
|
||||
id: "finding-1",
|
||||
labels: ["ProwlerFinding"],
|
||||
properties: { check_title: "Open S3 bucket", severity: "high" },
|
||||
};
|
||||
|
||||
const resourceNode: GraphNode = {
|
||||
id: "resource-1",
|
||||
labels: ["S3Bucket"],
|
||||
properties: { name: "bucket-1" },
|
||||
};
|
||||
|
||||
const internetNode: GraphNode = {
|
||||
id: "internet-1",
|
||||
labels: ["Internet"],
|
||||
properties: {},
|
||||
};
|
||||
|
||||
describe("layoutWithDagre", () => {
|
||||
it("returns empty arrays for empty input", () => {
|
||||
const result = layoutWithDagre([], []);
|
||||
expect(result.rfNodes).toEqual([]);
|
||||
expect(result.rfEdges).toEqual([]);
|
||||
});
|
||||
|
||||
it("assigns node types and dimensions from labels", () => {
|
||||
const { rfNodes } = layoutWithDagre(
|
||||
[findingNode, resourceNode, internetNode],
|
||||
[],
|
||||
);
|
||||
|
||||
const byId = new Map(rfNodes.map((n) => [n.id, n]));
|
||||
|
||||
expect(byId.get("finding-1")).toMatchObject({
|
||||
type: "finding",
|
||||
width: 200,
|
||||
height: 55,
|
||||
});
|
||||
expect(byId.get("resource-1")).toMatchObject({
|
||||
type: "resource",
|
||||
width: 180,
|
||||
height: 50,
|
||||
});
|
||||
expect(byId.get("internet-1")).toMatchObject({
|
||||
type: "internet",
|
||||
width: 80,
|
||||
height: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("is deterministic: same input produces equal output across runs", () => {
|
||||
const nodes = [findingNode, resourceNode];
|
||||
const edges: GraphEdge[] = [
|
||||
{
|
||||
id: "e1",
|
||||
source: "resource-1",
|
||||
target: "finding-1",
|
||||
type: "HAS_FINDING",
|
||||
},
|
||||
];
|
||||
|
||||
const a = layoutWithDagre(nodes, edges);
|
||||
const b = layoutWithDagre(nodes, edges);
|
||||
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("offsets dagre center positions by half of the node dimensions (top-left)", () => {
|
||||
const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []);
|
||||
|
||||
rfNodes.forEach((node) => {
|
||||
expect(Number.isFinite(node.position.x)).toBe(true);
|
||||
expect(Number.isFinite(node.position.y)).toBe(true);
|
||||
});
|
||||
|
||||
// Different node types must end up with different sizes — confirms the
|
||||
// dimension-aware offset is wired up.
|
||||
const findingDims = rfNodes.find((n) => n.id === "finding-1");
|
||||
const resourceDims = rfNodes.find((n) => n.id === "resource-1");
|
||||
expect(findingDims?.width).not.toEqual(resourceDims?.width);
|
||||
});
|
||||
|
||||
it("reverses container relationships while preserving original endpoints in edge data", () => {
|
||||
const containerNode: GraphNode = {
|
||||
id: "container",
|
||||
labels: ["AWSAccount"],
|
||||
properties: { name: "acct" },
|
||||
};
|
||||
const childNode: GraphNode = {
|
||||
id: "child",
|
||||
labels: ["S3Bucket"],
|
||||
properties: { name: "bucket" },
|
||||
};
|
||||
|
||||
const { rfEdges } = layoutWithDagre(
|
||||
[containerNode, childNode],
|
||||
[
|
||||
{
|
||||
id: "e1",
|
||||
source: "container",
|
||||
target: "child",
|
||||
type: "RUNS_IN",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(rfEdges).toHaveLength(1);
|
||||
expect(rfEdges[0]).toMatchObject({
|
||||
source: "child",
|
||||
target: "container",
|
||||
data: { originalSource: "container", originalTarget: "child" },
|
||||
});
|
||||
});
|
||||
|
||||
it("animates edges that touch a finding node and tags them with finding-edge", () => {
|
||||
const { rfEdges } = layoutWithDagre(
|
||||
[findingNode, resourceNode, internetNode],
|
||||
[
|
||||
{
|
||||
id: "e1",
|
||||
source: "resource-1",
|
||||
target: "finding-1",
|
||||
type: "HAS_FINDING",
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
source: "internet-1",
|
||||
target: "resource-1",
|
||||
type: "CONNECTS_TO",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const findingEdge = rfEdges.find(
|
||||
(e) => e.source === "resource-1" && e.target === "finding-1",
|
||||
);
|
||||
const plainEdge = rfEdges.find(
|
||||
(e) => e.source === "internet-1" && e.target === "resource-1",
|
||||
);
|
||||
|
||||
expect(findingEdge).toMatchObject({
|
||||
animated: true,
|
||||
className: "finding-edge",
|
||||
});
|
||||
expect(plainEdge).toMatchObject({
|
||||
animated: false,
|
||||
className: "resource-edge",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds rf edge IDs as `${source}-${target}` after layout", () => {
|
||||
const { rfEdges } = layoutWithDagre(
|
||||
[findingNode, resourceNode],
|
||||
[
|
||||
{
|
||||
id: "ignored-by-rf",
|
||||
source: "resource-1",
|
||||
target: "finding-1",
|
||||
type: "HAS_FINDING",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(rfEdges[0]?.id).toBe("resource-1-finding-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Pure Dagre layout adapter for React Flow
|
||||
* Converts normalized GraphNode[] + GraphEdge[] to positioned RF nodes
|
||||
*/
|
||||
|
||||
import { Graph, layout as dagreLayout } from "@dagrejs/dagre";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
// Node dimensions matching the original D3 implementation
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 50;
|
||||
const HEXAGON_WIDTH = 200;
|
||||
const HEXAGON_HEIGHT = 55;
|
||||
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
|
||||
|
||||
// Container relationships that get reversed for proper hierarchy
|
||||
const CONTAINER_RELATIONS = new Set([
|
||||
"RUNS_IN",
|
||||
"BELONGS_TO",
|
||||
"LOCATED_IN",
|
||||
"PART_OF",
|
||||
]);
|
||||
|
||||
interface NodeData extends Record<string, unknown> {
|
||||
graphNode: GraphNode;
|
||||
}
|
||||
|
||||
const NODE_TYPE = {
|
||||
FINDING: "finding",
|
||||
INTERNET: "internet",
|
||||
RESOURCE: "resource",
|
||||
} as const;
|
||||
|
||||
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
|
||||
|
||||
export const isFindingNode = (labels: string[]): boolean =>
|
||||
labels.some((l) => l.toLowerCase().includes("finding"));
|
||||
|
||||
const getNodeType = (labels: string[]): NodeType => {
|
||||
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
|
||||
if (labels.some((l) => l.toLowerCase() === "internet"))
|
||||
return NODE_TYPE.INTERNET;
|
||||
return NODE_TYPE.RESOURCE;
|
||||
};
|
||||
|
||||
const getNodeDimensions = (
|
||||
type: NodeType,
|
||||
): { width: number; height: number } => {
|
||||
if (type === NODE_TYPE.FINDING)
|
||||
return { width: HEXAGON_WIDTH, height: HEXAGON_HEIGHT };
|
||||
if (type === NODE_TYPE.INTERNET)
|
||||
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
|
||||
return { width: NODE_WIDTH, height: NODE_HEIGHT };
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure layout function: computes positioned React Flow nodes from graph data.
|
||||
* Deterministic — same inputs always produce same outputs.
|
||||
*/
|
||||
export const layoutWithDagre = (
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
|
||||
const g = new Graph();
|
||||
g.setGraph({
|
||||
rankdir: "LR",
|
||||
nodesep: 80,
|
||||
ranksep: 150,
|
||||
marginx: 50,
|
||||
marginy: 50,
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Add nodes with type-based dimensions
|
||||
nodes.forEach((node) => {
|
||||
const type = getNodeType(node.labels);
|
||||
const { width, height } = getNodeDimensions(type);
|
||||
g.setNode(node.id, { label: node.id, width, height });
|
||||
});
|
||||
|
||||
// Add edges, reversing container relationships for proper hierarchy
|
||||
edges.forEach((edge) => {
|
||||
let sourceId = edge.source;
|
||||
let targetId = edge.target;
|
||||
|
||||
if (CONTAINER_RELATIONS.has(edge.type)) {
|
||||
[sourceId, targetId] = [targetId, sourceId];
|
||||
}
|
||||
|
||||
if (sourceId && targetId) {
|
||||
g.setEdge(sourceId, targetId, {
|
||||
originalSource: edge.source,
|
||||
originalTarget: edge.target,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dagreLayout(g);
|
||||
|
||||
// Build RF nodes from layout
|
||||
const rfNodes: Node<NodeData>[] = nodes.map((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
const type = getNodeType(node.labels);
|
||||
const { width, height } = getNodeDimensions(type);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type,
|
||||
position: {
|
||||
x: dagreNode.x - width / 2,
|
||||
y: dagreNode.y - height / 2,
|
||||
},
|
||||
data: { graphNode: node },
|
||||
width,
|
||||
height,
|
||||
};
|
||||
});
|
||||
|
||||
// Build RF edges from dagre edges (using layout order, not original)
|
||||
const rfEdges: Edge[] = g.edges().map((e: { v: string; w: string }) => {
|
||||
const edgeData = g.edge(e) as {
|
||||
originalSource: string;
|
||||
originalTarget: string;
|
||||
};
|
||||
|
||||
// Check if either end is a finding node
|
||||
const sourceNode = nodes.find((n) => n.id === e.v);
|
||||
const targetNode = nodes.find((n) => n.id === e.w);
|
||||
const hasFinding =
|
||||
isFindingNode(sourceNode?.labels ?? []) ||
|
||||
isFindingNode(targetNode?.labels ?? []);
|
||||
|
||||
return {
|
||||
id: `${e.v}-${e.w}`,
|
||||
source: e.v,
|
||||
target: e.w,
|
||||
animated: hasFinding,
|
||||
className: hasFinding ? "finding-edge" : "resource-edge",
|
||||
data: {
|
||||
originalSource: edgeData.originalSource,
|
||||
originalTarget: edgeData.originalTarget,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { rfNodes, rfEdges };
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AmazonS3Icon,
|
||||
AmazonVPCIcon,
|
||||
AWSAccountIcon,
|
||||
AWSIAMIcon,
|
||||
} from "@/components/icons/services/IconServices";
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals";
|
||||
|
||||
const buildNode = (labels: string[], properties = {}): GraphNode => ({
|
||||
id: labels[0] ?? "unknown-node",
|
||||
labels,
|
||||
properties,
|
||||
});
|
||||
|
||||
describe("resolveNodeVisual", () => {
|
||||
describe("exact label mappings", () => {
|
||||
it("should resolve AWSAccount nodes to account metadata", () => {
|
||||
// Given
|
||||
const node = buildNode(["AWSAccount"], { name: "Production" });
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.ACCOUNT,
|
||||
displayName: "Production",
|
||||
description: "AWS Account",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
expect(visual.Icon).toBe(AWSAccountIcon);
|
||||
});
|
||||
|
||||
it("should resolve S3Bucket nodes to storage metadata", () => {
|
||||
// Given
|
||||
const node = buildNode(["S3Bucket"], { name: "public-assets" });
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.STORAGE,
|
||||
displayName: "public-assets",
|
||||
description: "S3 Bucket",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
expect(visual.Icon).toBe(AmazonS3Icon);
|
||||
});
|
||||
|
||||
it("should resolve VPC nodes to network metadata", () => {
|
||||
// Given
|
||||
const node = buildNode(["VPC"], { name: "main-vpc" });
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
displayName: "main-vpc",
|
||||
description: "VPC",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
expect(visual.Icon).toBe(AmazonVPCIcon);
|
||||
});
|
||||
|
||||
it("should resolve ProwlerFinding nodes to finding metadata", () => {
|
||||
// Given
|
||||
const node = buildNode(["ProwlerFinding"], {
|
||||
check_title: "S3 bucket is public",
|
||||
});
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.FINDING,
|
||||
displayName: "S3 bucket is public",
|
||||
description: "Prowler Finding",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve Internet nodes to internet metadata", () => {
|
||||
// Given
|
||||
const node = buildNode(["Internet"]);
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.INTERNET,
|
||||
displayName: "Internet",
|
||||
description: "Internet",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("alias and normalized mappings", () => {
|
||||
it("should resolve IAMUser nodes to identity metadata with the AWS IAM icon", () => {
|
||||
// Given
|
||||
const node = buildNode(["IAMUser"], { name: "alice" });
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
displayName: "alice",
|
||||
description: "IAM User",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
expect(visual.Icon).toBe(AWSIAMIcon);
|
||||
});
|
||||
|
||||
it("should resolve case-insensitive AccessKey labels to secret metadata", () => {
|
||||
// Given
|
||||
const node = buildNode(["access_key"], { id: "AKIA123" });
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.SECRET,
|
||||
displayName: "AKIA123",
|
||||
description: "Access Key",
|
||||
fallbackUsed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback behavior", () => {
|
||||
it("should use formatted labels for unknown nodes and mark the fallback", () => {
|
||||
// Given
|
||||
const node = buildNode(["CustomGraphNode"]);
|
||||
|
||||
// When
|
||||
const visual = resolveNodeVisual(node);
|
||||
|
||||
// Then
|
||||
expect(visual).toMatchObject({
|
||||
category: NODE_CATEGORY.MISC,
|
||||
displayName: "Custom Graph Node",
|
||||
description: "Custom Graph Node",
|
||||
fallbackUsed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Box,
|
||||
Globe2,
|
||||
KeyRound,
|
||||
Network,
|
||||
Server,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import type { ElementType } from "react";
|
||||
|
||||
import {
|
||||
AmazonEC2Icon,
|
||||
AmazonS3Icon,
|
||||
AmazonVPCIcon,
|
||||
AWSAccountIcon,
|
||||
AWSIAMIcon,
|
||||
} from "@/components/icons/services/IconServices";
|
||||
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
|
||||
|
||||
import { formatNodeLabel } from "./format";
|
||||
|
||||
export const NODE_CATEGORY = {
|
||||
FINDING: "finding",
|
||||
INTERNET: "internet",
|
||||
ACCOUNT: "account",
|
||||
STORAGE: "storage",
|
||||
NETWORK: "network",
|
||||
COMPUTE: "compute",
|
||||
IDENTITY: "identity",
|
||||
SECRET: "secret",
|
||||
MISC: "misc",
|
||||
} as const;
|
||||
|
||||
export type NodeCategory = (typeof NODE_CATEGORY)[keyof typeof NODE_CATEGORY];
|
||||
|
||||
interface KnownNodeVisualMapping {
|
||||
category: NodeCategory;
|
||||
description: string;
|
||||
Icon: ElementType;
|
||||
}
|
||||
|
||||
export interface NodeVisual extends KnownNodeVisualMapping {
|
||||
displayName: string;
|
||||
fallbackUsed: boolean;
|
||||
}
|
||||
|
||||
const KNOWN_NODE_VISUALS = {
|
||||
awsaccount: {
|
||||
category: NODE_CATEGORY.ACCOUNT,
|
||||
description: "AWS Account",
|
||||
Icon: AWSAccountIcon,
|
||||
},
|
||||
s3bucket: {
|
||||
category: NODE_CATEGORY.STORAGE,
|
||||
description: "S3 Bucket",
|
||||
Icon: AmazonS3Icon,
|
||||
},
|
||||
s3: {
|
||||
category: NODE_CATEGORY.STORAGE,
|
||||
description: "S3",
|
||||
Icon: AmazonS3Icon,
|
||||
},
|
||||
vpc: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "VPC",
|
||||
Icon: AmazonVPCIcon,
|
||||
},
|
||||
subnet: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Subnet",
|
||||
Icon: Network,
|
||||
},
|
||||
securitygroup: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Security Group",
|
||||
Icon: Network,
|
||||
},
|
||||
internetgateway: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Internet Gateway",
|
||||
Icon: Globe2,
|
||||
},
|
||||
defaultgateway: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Default Gateway",
|
||||
Icon: Globe2,
|
||||
},
|
||||
ec2instance: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
description: "EC2 Instance",
|
||||
Icon: AmazonEC2Icon,
|
||||
},
|
||||
virtualmachine: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
description: "Virtual Machine",
|
||||
Icon: AmazonEC2Icon,
|
||||
},
|
||||
compute: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
description: "Compute",
|
||||
Icon: Server,
|
||||
},
|
||||
nic: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
description: "NIC",
|
||||
Icon: Server,
|
||||
},
|
||||
iamuser: {
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
description: "IAM User",
|
||||
Icon: AWSIAMIcon,
|
||||
},
|
||||
iamrole: {
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
description: "IAM Role",
|
||||
Icon: AWSIAMIcon,
|
||||
},
|
||||
accesskey: {
|
||||
category: NODE_CATEGORY.SECRET,
|
||||
description: "Access Key",
|
||||
Icon: KeyRound,
|
||||
},
|
||||
secret: {
|
||||
category: NODE_CATEGORY.SECRET,
|
||||
description: "Secret",
|
||||
Icon: KeyRound,
|
||||
},
|
||||
serviceaccount: {
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
description: "Service Account",
|
||||
Icon: UserRound,
|
||||
},
|
||||
} as const satisfies Record<string, KnownNodeVisualMapping>;
|
||||
|
||||
type KnownNodeLabel = keyof typeof KNOWN_NODE_VISUALS;
|
||||
|
||||
const normalizeLabel = (label: string): string =>
|
||||
label.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
|
||||
label in KNOWN_NODE_VISUALS;
|
||||
|
||||
const isFindingLabel = (label: string): boolean =>
|
||||
normalizeLabel(label).includes("finding");
|
||||
|
||||
const isInternetLabel = (label: string): boolean =>
|
||||
normalizeLabel(label) === "internet";
|
||||
|
||||
const stringifyProperty = (
|
||||
value: GraphNodePropertyValue,
|
||||
): string | undefined => {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const firstDefinedProperty = (
|
||||
node: GraphNode,
|
||||
keys: string[],
|
||||
): string | undefined => {
|
||||
for (const key of keys) {
|
||||
const value = stringifyProperty(node.properties[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getPrimaryFormattedLabel = (node: GraphNode): string => {
|
||||
const primaryLabel = node.labels[0];
|
||||
if (!primaryLabel) return "Unknown";
|
||||
return formatNodeLabel(primaryLabel.replace(/[_-]/g, " "));
|
||||
};
|
||||
|
||||
const resolveDisplayName = (node: GraphNode): string =>
|
||||
firstDefinedProperty(node, ["name", "display_name", "title", "id"]) ??
|
||||
getPrimaryFormattedLabel(node);
|
||||
|
||||
const resolveFindingDisplayName = (node: GraphNode): string =>
|
||||
firstDefinedProperty(node, ["check_title", "title", "name", "id"]) ??
|
||||
getPrimaryFormattedLabel(node);
|
||||
|
||||
const resolveKnownMapping = (
|
||||
labels: string[],
|
||||
): KnownNodeVisualMapping | undefined => {
|
||||
for (const label of labels) {
|
||||
const normalizedLabel = normalizeLabel(label);
|
||||
if (isKnownNodeLabel(normalizedLabel)) {
|
||||
return KNOWN_NODE_VISUALS[normalizedLabel];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const resolveNodeVisual = (node: GraphNode): NodeVisual => {
|
||||
if (node.labels.some(isFindingLabel)) {
|
||||
return {
|
||||
category: NODE_CATEGORY.FINDING,
|
||||
displayName: resolveFindingDisplayName(node),
|
||||
description: "Prowler Finding",
|
||||
Icon: AlertTriangle,
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.labels.some(isInternetLabel)) {
|
||||
return {
|
||||
category: NODE_CATEGORY.INTERNET,
|
||||
displayName: "Internet",
|
||||
description: "Internet",
|
||||
Icon: Globe2,
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const knownMapping = resolveKnownMapping(node.labels);
|
||||
if (knownMapping) {
|
||||
return {
|
||||
...knownMapping,
|
||||
displayName: resolveDisplayName(node),
|
||||
fallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackLabel = getPrimaryFormattedLabel(node);
|
||||
|
||||
return {
|
||||
category: NODE_CATEGORY.MISC,
|
||||
displayName: resolveDisplayName(node),
|
||||
description: fallbackLabel,
|
||||
Icon: Box,
|
||||
fallbackUsed: true,
|
||||
};
|
||||
};
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Browser-mode tests for <AttackPathsPage />.
|
||||
*
|
||||
* Tests are grouped by user-perceived flow, not by internal spec taxonomy. Each
|
||||
* test interacts with the page ONLY through `AttackPathPageHarness`. Each test:
|
||||
* 1. picks a fixture
|
||||
* 2. calls `mountWith(fx)` — wires MSW handlers, sets the URL, mounts the page
|
||||
* 3. drives the harness
|
||||
*
|
||||
* If you find yourself reaching for a DOM query in a test, push it into the harness.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test as base } from "vitest";
|
||||
|
||||
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
import AttackPathsPage from "./attack-paths-page";
|
||||
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
|
||||
import { AttackPathPageHarness } from "./attack-paths-page.harness";
|
||||
|
||||
interface Fixtures {
|
||||
mountWith: (fx?: PageFixture) => Promise<AttackPathPageHarness>;
|
||||
}
|
||||
|
||||
// The graph store is module-scoped, so it survives across tests in the same
|
||||
// file. Reset it before each test so no test sees stale state from a previous
|
||||
// one (selection, filtered view, expanded resources, etc.).
|
||||
beforeEach(() => {
|
||||
useGraphStore.getState().reset();
|
||||
});
|
||||
|
||||
const test = base.extend<Fixtures>({
|
||||
mountWith: async ({}, use) => {
|
||||
// `use` is Vitest's fixture-injection callback, not React's `use` hook.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
await use(async (fx = fixtures.typical()) => {
|
||||
worker.use(...handlersForFixture(fx));
|
||||
window.history.replaceState({}, "", `/attack-paths?scanId=${fx.scanId}`);
|
||||
await render(<AttackPathsPage />);
|
||||
return new AttackPathPageHarness(fx);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
describe("loading the page", () => {
|
||||
test("an account with no scans shows the empty state", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.emptyScans());
|
||||
expect(await graph.emptyStateMessage()).toMatch(/No scans available/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("running a query", () => {
|
||||
test("the graph renders with a background, a minimap, and a viewport", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.background).toBeTruthy();
|
||||
expect(graph.minimap).toBeTruthy();
|
||||
expect(graph.viewport).toBeTruthy();
|
||||
});
|
||||
|
||||
test("nodes are laid out at distinct positions", async ({ mountWith }) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
const positions = graph.nodePositions;
|
||||
expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("the toolbar exposes zoom, fit, and export controls", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(1);
|
||||
|
||||
expect(graph.toolbar.zoomInButton).toBeTruthy();
|
||||
expect(graph.toolbar.zoomOutButton).toBeTruthy();
|
||||
expect(graph.toolbar.fitButton).toBeTruthy();
|
||||
expect(graph.toolbar.exportButton).toBeTruthy();
|
||||
});
|
||||
|
||||
test("finding, resource, and internet nodes all render", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
expect(graph.resourceNodes.length).toBeGreaterThan(0);
|
||||
expect(graph.internetNodes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("only edges connected to a finding are animated", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
expect(graph.findingEdges.length).toBeGreaterThan(0);
|
||||
expect(graph.resourceEdges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("edges connect string source and target ids", async ({ mountWith }) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(2);
|
||||
|
||||
const edgeIds = graph.renderedEdgeIds;
|
||||
expect(edgeIds.length).toBeGreaterThan(0);
|
||||
for (const id of edgeIds) {
|
||||
expect(id).toMatch(/^[\w-]+-[\w-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("a query that returns one node renders just that node", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.singleNode());
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(1);
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a query that returns no graph data surfaces an error without crashing", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.emptyGraph());
|
||||
try {
|
||||
await graph.executeQuery();
|
||||
} catch {
|
||||
/* expected: layout never stabilizes */
|
||||
}
|
||||
expect(graph.nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a 200-node graph finishes laying out within 5s", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.large(200));
|
||||
const start = performance.now();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(1);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test("disconnected components are both visible", async ({ mountWith }) => {
|
||||
const graph = await mountWith(fixtures.disconnected());
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(4);
|
||||
expect(graph.nodes.length).toBe(4);
|
||||
});
|
||||
|
||||
test("a query that returns only resources renders no findings", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.resourcesOnly());
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
expect(graph.resourceNodes.length).toBe(3);
|
||||
});
|
||||
|
||||
test("findings without a connected resource are hidden by default", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
// Tier 1 view: unattached findings stay hidden until the user expands
|
||||
// their adjacent resource — none here, so nothing renders.
|
||||
const graph = await mountWith(fixtures.findingsOnly());
|
||||
try {
|
||||
await graph.executeQuery();
|
||||
} catch {
|
||||
/* expected: nothing visible, layout never stabilizes */
|
||||
}
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
expect(graph.resourceNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test("self-loops, cycles, long labels, unicode, and duplicate edges all render", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith(fixtures.edgeCases());
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(5);
|
||||
|
||||
expect(graph.nodes.length).toBe(7);
|
||||
expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exploring the graph", () => {
|
||||
test("clicking a finding opens the filtered view", async ({ mountWith }) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("clicking a resource with findings opens the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("choosing Show findings reveals related finding nodes", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("expanded resources offer Hide findings in the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.containsText(/Hide findings/i)).toBe(true);
|
||||
|
||||
await graph.chooseHideFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test("choosing View node details opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseViewNodeDetailsAction();
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a resource without findings opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
|
||||
await graph.clickFirstResourceNodeWithoutFindings();
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a parent node in filtered view asks whether to go back or view details", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.containsText(/Back to full graph/i)).toBe(true);
|
||||
|
||||
await graph.chooseBackToFullGraphAction();
|
||||
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
});
|
||||
|
||||
test("exiting the filtered view restores the full graph", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
const fullNodes = graph.nodes.length;
|
||||
await graph.clickFirstFindingNode();
|
||||
await graph.exitFilteredView();
|
||||
await graph.waitForLayoutStable(fullNodes);
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
});
|
||||
|
||||
test("hovering a node highlights its path edges", async ({ mountWith }) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.hoverFirstResourceNode();
|
||||
await graph.waitForTransition(120);
|
||||
expect(graph.highlightedEdges.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await graph.unhoverNodes();
|
||||
await graph.waitForTransition(120);
|
||||
expect(graph.highlightedEdges.length).toBe(0);
|
||||
});
|
||||
|
||||
test("clicking the empty canvas keeps the full graph", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickEmptyCanvas();
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
});
|
||||
|
||||
test("rapid clicks on a finding don't duplicate the filtered view", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
await graph.rapidlyClickFirstFindingNode(2);
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
});
|
||||
|
||||
test("double-clicking a node doesn't break state", async ({ mountWith }) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.dblClickFirstResourceNode();
|
||||
expect(graph.nodes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto-fitting the viewport", () => {
|
||||
test("the minimap viewport indicator has a visible border", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("expanding resources re-fits the viewport when revealed findings fall off-screen", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
// Given - zoom into the current overview so newly revealed findings can
|
||||
// sit entirely outside the current frame. The expand auto-fit should then
|
||||
// recover the user instead of leaving them hunting off-screen.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await graph.zoomIn();
|
||||
await graph.waitForTransition(80);
|
||||
}
|
||||
|
||||
// Hidden findings are not measured by the initial declarative fit, so
|
||||
// their positions can sit outside the framed viewport. Expanding the
|
||||
// resources should re-fit so the user does not have to hunt for the
|
||||
// newly visible findings off-screen.
|
||||
const before = graph.viewportTransform;
|
||||
expect(before).toBeTruthy();
|
||||
|
||||
await graph.expandAllFindings();
|
||||
await graph.waitForTransition();
|
||||
|
||||
expect(graph.viewportTransform).not.toBe(before);
|
||||
});
|
||||
|
||||
test("clicking a finding re-fits the viewport for the filtered subgraph", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
const beforeFilter = graph.viewportTransform;
|
||||
expect(beforeFilter).toBeTruthy();
|
||||
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
await graph.waitForTransition();
|
||||
|
||||
expect(graph.viewportTransform).not.toBe(beforeFilter);
|
||||
});
|
||||
|
||||
test("Back to Full View re-fits the viewport for the full graph", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
await graph.waitForTransition();
|
||||
const filterT = graph.viewportTransform;
|
||||
|
||||
await graph.exitFilteredView();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.waitForTransition();
|
||||
|
||||
expect(graph.viewportTransform).not.toBe(filterT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exporting the graph", () => {
|
||||
test("the export button is enabled when a graph is rendered", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.toolbar.isExportButtonEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking export downloads a PNG sized to the configured export canvas", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
|
||||
const png = await graph.captureExportPNG();
|
||||
|
||||
expect(png.filename).toBe("attack-path-graph.png");
|
||||
expect(png.mimeType).toBe("image/png");
|
||||
// Regressions in the viewport element passed to `domToPng`, the
|
||||
// configured export size, or the bounds-driven viewport transform
|
||||
// fail loudly here.
|
||||
expect(png.width).toBe(1920);
|
||||
expect(png.height).toBe(1080);
|
||||
});
|
||||
});
|
||||
|
||||
describe("running a different query", () => {
|
||||
test("the previous filtered view is cleared", async ({ mountWith }) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
await graph.expandAllFindings();
|
||||
await graph.clickFirstFindingNode();
|
||||
expect(graph.isInFilteredView).toBe(true);
|
||||
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
expect(graph.isInFilteredView).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Typed fixture builders for <AttackPathsPage /> browser tests.
|
||||
*
|
||||
* Each builder returns a self-contained snapshot of the API surface the page
|
||||
* exercises: scans list, available queries, and query execution result. The
|
||||
* MSW handler factory in the harness turns a fixture into HTTP mocks.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
GraphRelationship,
|
||||
QueryResultAttributes,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
export interface PageFixture {
|
||||
scans: AttackPathScan[];
|
||||
scanId: string;
|
||||
queries: AttackPathQuery[];
|
||||
queryId: string;
|
||||
queryResult: QueryResultAttributes | null;
|
||||
queryError?: { status: number; error: string };
|
||||
}
|
||||
|
||||
const TYPICAL_SCAN_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const SECOND_SCAN_ID = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const DEFAULT_QUERY_ID = "aws-public-s3-buckets";
|
||||
|
||||
const buildScan = (
|
||||
id: string,
|
||||
overrides: Partial<AttackPathScan["attributes"]> = {},
|
||||
): AttackPathScan => ({
|
||||
type: "attack-paths-scans",
|
||||
id,
|
||||
attributes: {
|
||||
state: "completed",
|
||||
progress: 100,
|
||||
graph_data_ready: true,
|
||||
provider_alias: `Provider ${id.slice(0, 4)}`,
|
||||
provider_type: "aws",
|
||||
provider_uid: `123456789${id.slice(0, 3)}`,
|
||||
inserted_at: "2026-04-21T10:00:00Z",
|
||||
started_at: "2026-04-21T10:00:00Z",
|
||||
completed_at: "2026-04-21T10:05:00Z",
|
||||
duration: 300,
|
||||
...overrides,
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: `provider-${id}` } },
|
||||
scan: { data: { type: "scans", id: `base-scan-${id}` } },
|
||||
task: { data: { type: "tasks", id: `task-${id}` } },
|
||||
},
|
||||
});
|
||||
|
||||
const buildQuery = (
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<AttackPathQuery["attributes"]> = {},
|
||||
): AttackPathQuery => ({
|
||||
type: "attack-paths-scans",
|
||||
id,
|
||||
attributes: {
|
||||
name,
|
||||
short_description: `Run the ${name} query`,
|
||||
description: `Detailed description for ${name}.`,
|
||||
provider: "aws",
|
||||
parameters: [],
|
||||
attribution: null,
|
||||
documentation_link: null,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const buildResourceNode = (
|
||||
id: string,
|
||||
label: string,
|
||||
name: string,
|
||||
extraLabels: string[] = [],
|
||||
): GraphNode => ({
|
||||
id,
|
||||
labels: [label, ...extraLabels],
|
||||
properties: { id, name, arn: `arn:aws:example::${id}` },
|
||||
});
|
||||
|
||||
const buildFindingNode = (
|
||||
id: string,
|
||||
title: string,
|
||||
severity = "high",
|
||||
): GraphNode => ({
|
||||
id,
|
||||
labels: ["ProwlerFinding"],
|
||||
properties: { id, check_title: title, severity, status: "FAIL" },
|
||||
});
|
||||
|
||||
const buildInternetNode = (): GraphNode => ({
|
||||
id: "internet",
|
||||
labels: ["Internet"],
|
||||
properties: { id: "internet", name: "Internet" },
|
||||
});
|
||||
|
||||
const buildRel = (
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
label: string,
|
||||
): GraphRelationship => ({ id, source, target, label });
|
||||
|
||||
export const typical = (): PageFixture => {
|
||||
const nodes: GraphNode[] = [
|
||||
buildInternetNode(),
|
||||
buildResourceNode("ec2-1", "EC2Instance", "api-server-01"),
|
||||
buildResourceNode("s3-1", "S3Bucket", "private-data-bucket"),
|
||||
buildResourceNode("iam-1", "IAMRole", "AppRole"),
|
||||
buildFindingNode("f-1", "S3 bucket is public", "critical"),
|
||||
buildFindingNode("f-2", "EC2 exposed to internet", "high"),
|
||||
];
|
||||
const relationships: GraphRelationship[] = [
|
||||
buildRel("r1", "internet", "ec2-1", "CAN_REACH"),
|
||||
buildRel("r2", "ec2-1", "s3-1", "CAN_ACCESS"),
|
||||
buildRel("r3", "ec2-1", "iam-1", "ASSUMES"),
|
||||
buildRel("r4", "s3-1", "f-1", "HAS_FINDING"),
|
||||
buildRel("r5", "ec2-1", "f-2", "HAS_FINDING"),
|
||||
];
|
||||
return {
|
||||
scans: [buildScan(TYPICAL_SCAN_ID), buildScan(SECOND_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [
|
||||
buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets"),
|
||||
buildQuery("aws-open-security-groups", "Open security groups"),
|
||||
],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: { nodes, relationships },
|
||||
};
|
||||
};
|
||||
|
||||
export const emptyScans = (): PageFixture => ({
|
||||
scans: [],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: null,
|
||||
});
|
||||
|
||||
export const emptyGraph = (): PageFixture => ({
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: null,
|
||||
queryError: { status: 404, error: "No data found" },
|
||||
});
|
||||
|
||||
export const singleNode = (): PageFixture => ({
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: {
|
||||
nodes: [buildResourceNode("only-1", "S3Bucket", "solitary-bucket")],
|
||||
relationships: [],
|
||||
},
|
||||
});
|
||||
|
||||
export const findingsOnly = (): PageFixture => ({
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Findings only")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: {
|
||||
nodes: [
|
||||
buildFindingNode("f-1", "Finding A", "critical"),
|
||||
buildFindingNode("f-2", "Finding B", "high"),
|
||||
buildFindingNode("f-3", "Finding C", "medium"),
|
||||
],
|
||||
relationships: [],
|
||||
},
|
||||
});
|
||||
|
||||
export const resourcesOnly = (): PageFixture => ({
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Resources only")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: {
|
||||
nodes: [
|
||||
buildResourceNode("ec2-1", "EC2Instance", "web-1"),
|
||||
buildResourceNode("ec2-2", "EC2Instance", "web-2"),
|
||||
buildResourceNode("s3-1", "S3Bucket", "logs"),
|
||||
],
|
||||
relationships: [
|
||||
buildRel("r1", "ec2-1", "s3-1", "CAN_ACCESS"),
|
||||
buildRel("r2", "ec2-2", "s3-1", "CAN_ACCESS"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const disconnected = (): PageFixture => ({
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Disconnected components")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: {
|
||||
nodes: [
|
||||
buildResourceNode("a-1", "EC2Instance", "alpha-ec2"),
|
||||
buildResourceNode("a-2", "S3Bucket", "alpha-s3"),
|
||||
buildResourceNode("b-1", "EC2Instance", "beta-ec2"),
|
||||
buildResourceNode("b-2", "S3Bucket", "beta-s3"),
|
||||
],
|
||||
relationships: [
|
||||
buildRel("r1", "a-1", "a-2", "CAN_ACCESS"),
|
||||
buildRel("r2", "b-1", "b-2", "CAN_ACCESS"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const large = (count = 200): PageFixture => {
|
||||
const nodes: GraphNode[] = [];
|
||||
const relationships: GraphRelationship[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `n-${i}`;
|
||||
if (i % 5 === 0) {
|
||||
nodes.push(buildFindingNode(id, `Finding ${i}`, "high"));
|
||||
} else {
|
||||
nodes.push(buildResourceNode(id, "EC2Instance", `instance-${i}`));
|
||||
}
|
||||
if (i > 0) {
|
||||
relationships.push(buildRel(`r-${i}`, `n-${i - 1}`, id, "CAN_REACH"));
|
||||
}
|
||||
}
|
||||
return {
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Large graph")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: { nodes, relationships },
|
||||
};
|
||||
};
|
||||
|
||||
export const edgeCases = (): PageFixture => {
|
||||
const longLabel =
|
||||
"a very long resource name that should be truncated ".repeat(4);
|
||||
const nodes: GraphNode[] = [
|
||||
buildResourceNode("self-1", "EC2Instance", "self-loop"),
|
||||
buildResourceNode("cy-1", "EC2Instance", "cycle-a"),
|
||||
buildResourceNode("cy-2", "EC2Instance", "cycle-b"),
|
||||
buildResourceNode("long-1", "EC2Instance", longLabel),
|
||||
buildResourceNode("emoji-1", "S3Bucket", "🔒-secure-bucket-日本語"),
|
||||
buildResourceNode("dup-a", "EC2Instance", "dup-source"),
|
||||
buildResourceNode("dup-b", "S3Bucket", "dup-target"),
|
||||
];
|
||||
const relationships: GraphRelationship[] = [
|
||||
buildRel("self-edge", "self-1", "self-1", "REFERS_TO"),
|
||||
buildRel("cy-a", "cy-1", "cy-2", "CAN_REACH"),
|
||||
buildRel("cy-b", "cy-2", "cy-1", "CAN_REACH"),
|
||||
buildRel("dup-1", "dup-a", "dup-b", "CAN_ACCESS"),
|
||||
buildRel("dup-2", "dup-a", "dup-b", "CAN_ACCESS"),
|
||||
];
|
||||
return {
|
||||
scans: [buildScan(TYPICAL_SCAN_ID)],
|
||||
scanId: TYPICAL_SCAN_ID,
|
||||
queries: [buildQuery(DEFAULT_QUERY_ID, "Edge cases")],
|
||||
queryId: DEFAULT_QUERY_ID,
|
||||
queryResult: { nodes, relationships },
|
||||
};
|
||||
};
|
||||
|
||||
export const fixtures = {
|
||||
typical,
|
||||
emptyScans,
|
||||
emptyGraph,
|
||||
singleNode,
|
||||
findingsOnly,
|
||||
resourcesOnly,
|
||||
disconnected,
|
||||
large,
|
||||
edgeCases,
|
||||
};
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Test harness for <AttackPathsPage /> browser-mode tests.
|
||||
*
|
||||
* Selectors + flows only. Mounting and MSW setup live in the test file.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import type { PageFixture } from "./attack-paths-page.fixtures";
|
||||
|
||||
export class AttackPathPageHarness {
|
||||
private static readonly NODE_SEL = ".react-flow__node";
|
||||
private static readonly EDGE_SEL = ".react-flow__edge";
|
||||
private static readonly VIEWPORT_SEL = ".react-flow__viewport";
|
||||
private static readonly MINIMAP_SEL = ".react-flow__minimap";
|
||||
private static readonly BACKGROUND_SEL = ".react-flow__background";
|
||||
|
||||
private static isFindingElement(el: Element): boolean {
|
||||
return (
|
||||
el.classList.contains("react-flow__node-finding") ||
|
||||
el.getAttribute("data-nodetype") === "finding"
|
||||
);
|
||||
}
|
||||
|
||||
private static isResourceElement(el: Element): boolean {
|
||||
return (
|
||||
el.classList.contains("react-flow__node-resource") ||
|
||||
el.getAttribute("data-nodetype") === "resource"
|
||||
);
|
||||
}
|
||||
|
||||
private static isInternetElement(el: Element): boolean {
|
||||
return (
|
||||
el.classList.contains("react-flow__node-internet") ||
|
||||
el.getAttribute("data-nodetype") === "internet"
|
||||
);
|
||||
}
|
||||
|
||||
readonly user = userEvent;
|
||||
|
||||
constructor(readonly fixture: PageFixture) {}
|
||||
|
||||
// --- Container ---
|
||||
|
||||
get container(): HTMLElement {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
// --- Collections ---
|
||||
|
||||
get nodes(): HTMLElement[] {
|
||||
return Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>(
|
||||
AttackPathPageHarness.NODE_SEL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get edges(): HTMLElement[] {
|
||||
return Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>(
|
||||
AttackPathPageHarness.EDGE_SEL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get findingNodes(): HTMLElement[] {
|
||||
return this.nodes.filter(AttackPathPageHarness.isFindingElement);
|
||||
}
|
||||
|
||||
get resourceNodes(): HTMLElement[] {
|
||||
return this.nodes.filter(AttackPathPageHarness.isResourceElement);
|
||||
}
|
||||
|
||||
get internetNodes(): HTMLElement[] {
|
||||
return this.nodes.filter(AttackPathPageHarness.isInternetElement);
|
||||
}
|
||||
|
||||
get findingEdges(): HTMLElement[] {
|
||||
return this.edges.filter((e) => e.classList.contains("finding-edge"));
|
||||
}
|
||||
|
||||
get resourceEdges(): HTMLElement[] {
|
||||
return this.edges.filter((e) => e.classList.contains("resource-edge"));
|
||||
}
|
||||
|
||||
get highlightedEdges(): HTMLElement[] {
|
||||
return this.edges.filter((e) => e.classList.contains("highlighted"));
|
||||
}
|
||||
|
||||
get renderedNodeIds(): string[] {
|
||||
return this.nodes.map((el) => el.getAttribute("data-id") ?? "");
|
||||
}
|
||||
|
||||
get renderedEdgeIds(): string[] {
|
||||
return this.edges.map((el) => el.getAttribute("data-id") ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed `translate(x, y)` from each rendered node's transform style.
|
||||
* Useful for asserting layout actually placed nodes (non-zero, distinct, …).
|
||||
*/
|
||||
get nodePositions(): Array<{ x: number; y: number }> {
|
||||
return this.nodes.map((el) => {
|
||||
const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec(
|
||||
el.style.transform,
|
||||
);
|
||||
return match
|
||||
? { x: Number(match[1]), y: Number(match[2]) }
|
||||
: { x: 0, y: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Predicates ---
|
||||
|
||||
get isInFilteredView(): boolean {
|
||||
return !!this.container.querySelector(
|
||||
'[aria-label="Return to full graph view"]',
|
||||
);
|
||||
}
|
||||
|
||||
isNodeSelected(nodeId: string): boolean {
|
||||
const el = this.getNodeById(nodeId);
|
||||
return !!el && el.classList.contains("selected");
|
||||
}
|
||||
|
||||
isEdgeHighlighted(edgeId: string): boolean {
|
||||
const el = this.getEdgeById(edgeId);
|
||||
return !!el && el.classList.contains("highlighted");
|
||||
}
|
||||
|
||||
isNodeHidden(nodeId: string): boolean {
|
||||
return !this.getNodeById(nodeId);
|
||||
}
|
||||
|
||||
// --- Lookups ---
|
||||
|
||||
getNodeById(id: string): HTMLElement | null {
|
||||
return this.container.querySelector<HTMLElement>(
|
||||
`${AttackPathPageHarness.NODE_SEL}[data-id="${id}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
getEdgeById(id: string): HTMLElement | null {
|
||||
return this.container.querySelector<HTMLElement>(
|
||||
`${AttackPathPageHarness.EDGE_SEL}[data-id="${id}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Handles ---
|
||||
|
||||
private q(selector: string): HTMLElement | null {
|
||||
return this.container.querySelector<HTMLElement>(selector);
|
||||
}
|
||||
|
||||
get toolbar() {
|
||||
const exportButton =
|
||||
this.q('button[aria-label="Export graph"]') ??
|
||||
this.q('button[aria-label="Export available soon"]');
|
||||
return {
|
||||
zoomInButton: this.q('button[aria-label="Zoom in"]'),
|
||||
zoomOutButton: this.q('button[aria-label="Zoom out"]'),
|
||||
fitButton: this.q('button[aria-label="Fit graph to view"]'),
|
||||
exportButton,
|
||||
isExportButtonEnabled:
|
||||
!!exportButton && !(exportButton as HTMLButtonElement).disabled,
|
||||
backToFullViewButton: this.q(
|
||||
'button[aria-label="Return to full graph view"]',
|
||||
),
|
||||
fullscreenButton: this.q('button[aria-label="Fullscreen"]'),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Page-level surface (alerts, raw text) ---
|
||||
|
||||
/** Wait for a `[role="alert"]` to appear and return its text. */
|
||||
async emptyStateMessage(timeoutMs = 2000): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() => this.container.querySelector<HTMLElement>('[role="alert"]'),
|
||||
timeoutMs,
|
||||
);
|
||||
return alert.textContent ?? "";
|
||||
}
|
||||
|
||||
/** True when the rendered page contains text matching `pattern`. */
|
||||
containsText(pattern: RegExp): boolean {
|
||||
return pattern.test(this.container.textContent ?? "");
|
||||
}
|
||||
|
||||
get minimap(): HTMLElement | null {
|
||||
return this.q(AttackPathPageHarness.MINIMAP_SEL);
|
||||
}
|
||||
|
||||
get background(): HTMLElement | null {
|
||||
return this.q(AttackPathPageHarness.BACKGROUND_SEL);
|
||||
}
|
||||
|
||||
get viewport(): HTMLElement | null {
|
||||
return this.q(AttackPathPageHarness.VIEWPORT_SEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline `transform` of the React Flow viewport element. This is the
|
||||
* pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing
|
||||
* it before vs. after a user action is enough to assert that the viewport
|
||||
* actually moved (or stayed put).
|
||||
*/
|
||||
get viewportTransform(): string {
|
||||
return this.viewport?.style.transform ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* `stroke-width` of the minimap mask SVG. The mask cuts out the area
|
||||
* currently in view; the cut-out's border is what indicates the viewport
|
||||
* inside the minimap. A non-zero stroke-width means the indicator has a
|
||||
* visible border (rather than blending into the dark theme background).
|
||||
*/
|
||||
get minimapMaskStrokeWidth(): number {
|
||||
const mask = this.minimap?.querySelector<SVGPathElement>(
|
||||
".react-flow__minimap-mask",
|
||||
);
|
||||
if (!mask) return 0;
|
||||
const inline = mask.getAttribute("stroke-width");
|
||||
if (inline) return Number.parseFloat(inline);
|
||||
const computed = getComputedStyle(mask).strokeWidth;
|
||||
return Number.parseFloat(computed);
|
||||
}
|
||||
|
||||
get fullscreenDialog(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
}
|
||||
|
||||
get nodeDetailsHeading(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
|
||||
).find((heading) => /^Node Details$/i.test(heading.textContent ?? "")) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
get hasNodeDetailsModal(): boolean {
|
||||
return !!this.nodeDetailsHeading;
|
||||
}
|
||||
|
||||
get nodeActionHeading(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[role='dialog'] h2"),
|
||||
).find((heading) =>
|
||||
/^Choose node action$/i.test(heading.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
get hasNodeActionDialog(): boolean {
|
||||
return !!this.nodeActionHeading;
|
||||
}
|
||||
|
||||
// --- Sync helpers ---
|
||||
|
||||
/** Wait until React Flow has rendered at least `expected` node elements. */
|
||||
async waitForLayoutStable(expected = 1, timeoutMs = 3000): Promise<void> {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (this.nodes.length < expected) {
|
||||
throw new Error(
|
||||
`expected ${expected} nodes, got ${this.nodes.length}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ timeout: timeoutMs, interval: 16 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait until the predicate returns truthy and return that value. */
|
||||
async waitFor<T>(
|
||||
fn: () => T | null | undefined | false,
|
||||
timeoutMs = 3000,
|
||||
): Promise<T> {
|
||||
return vi.waitFor(
|
||||
() => {
|
||||
const v = fn();
|
||||
if (!v) throw new Error("waitFor predicate not yet truthy");
|
||||
return v;
|
||||
},
|
||||
{ timeout: timeoutMs, interval: 16 },
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
async waitForTransition(ms = 350): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// --- Action methods ---
|
||||
|
||||
async selectQuery(queryId?: string): Promise<void> {
|
||||
const trigger = await this.waitFor<HTMLButtonElement>(
|
||||
() =>
|
||||
this.container.querySelector<HTMLButtonElement>(
|
||||
'button[role="combobox"]',
|
||||
),
|
||||
10000,
|
||||
);
|
||||
await this.user.click(trigger);
|
||||
|
||||
const targetId = queryId ?? this.fixture.queryId;
|
||||
const targetName = this.fixture.queries.find((q) => q.id === targetId)
|
||||
?.attributes.name;
|
||||
|
||||
const option = await this.waitFor<HTMLElement>(
|
||||
() =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`[role="option"][data-value="${targetId}"]`,
|
||||
) ??
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).find((el) => targetName && el.textContent?.includes(targetName)),
|
||||
10000,
|
||||
);
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async executeQuery(options: { selectFirst?: boolean } = {}): Promise<void> {
|
||||
if (options.selectFirst !== false) {
|
||||
await this.selectQuery();
|
||||
}
|
||||
|
||||
const button = await this.waitFor<HTMLButtonElement>(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find(
|
||||
(b) =>
|
||||
!b.disabled &&
|
||||
/execute query/i.test(b.textContent ?? "") &&
|
||||
!/executing/i.test(b.textContent ?? ""),
|
||||
),
|
||||
10000,
|
||||
);
|
||||
await this.user.click(button);
|
||||
await this.waitForLayoutStable(1, 10000);
|
||||
}
|
||||
|
||||
async clickNode(nodeId: string): Promise<void> {
|
||||
const el = this.getNodeById(nodeId);
|
||||
if (!el) throw new Error(`clickNode: node "${nodeId}" not found`);
|
||||
await this.user.click(el);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async clickFirstFindingNode(): Promise<HTMLElement> {
|
||||
const [finding] = this.findingNodes;
|
||||
if (!finding) throw new Error("clickFirstFindingNode: no finding rendered");
|
||||
await this.user.click(finding);
|
||||
await this.waitForTransition();
|
||||
return finding;
|
||||
}
|
||||
|
||||
async clickFirstResourceNode(): Promise<HTMLElement> {
|
||||
const [resource] = this.resourceNodes;
|
||||
if (!resource)
|
||||
throw new Error("clickFirstResourceNode: no resource rendered");
|
||||
await this.user.click(resource);
|
||||
await this.waitForTransition();
|
||||
return resource;
|
||||
}
|
||||
|
||||
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
|
||||
const findingIds = new Set(
|
||||
(this.fixture.queryResult?.nodes ?? [])
|
||||
.filter((n) =>
|
||||
n.labels.some((l) => l.toLowerCase().includes("finding")),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const resourceWithFindingIds = new Set<string>();
|
||||
for (const rel of this.fixture.queryResult?.relationships ?? []) {
|
||||
if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target);
|
||||
if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source);
|
||||
}
|
||||
const resource = this.resourceNodes.find((node) => {
|
||||
const id = node.getAttribute("data-id");
|
||||
return id && !resourceWithFindingIds.has(id);
|
||||
});
|
||||
if (!resource) {
|
||||
throw new Error(
|
||||
"clickFirstResourceNodeWithoutFindings: no resource without findings rendered",
|
||||
);
|
||||
}
|
||||
await this.user.click(resource);
|
||||
await this.waitForTransition();
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first finding `times` times back-to-back, with no transition
|
||||
* waits between clicks. Used for rapid-click race tests.
|
||||
*/
|
||||
async rapidlyClickFirstFindingNode(times = 2): Promise<HTMLElement> {
|
||||
const [finding] = this.findingNodes;
|
||||
if (!finding)
|
||||
throw new Error("rapidlyClickFirstFindingNode: no finding rendered");
|
||||
for (let i = 0; i < times; i++) {
|
||||
await this.user.click(finding);
|
||||
}
|
||||
await this.waitForTransition();
|
||||
return finding;
|
||||
}
|
||||
|
||||
async dblClickFirstResourceNode(): Promise<HTMLElement> {
|
||||
const [resource] = this.resourceNodes;
|
||||
if (!resource)
|
||||
throw new Error("dblClickFirstResourceNode: no resource rendered");
|
||||
await this.user.dblClick(resource);
|
||||
await this.waitForTransition();
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the React Flow background pane (anywhere not on a node/edge), used
|
||||
* to verify that empty-canvas clicks don't open the filtered view.
|
||||
*/
|
||||
async clickEmptyCanvas(): Promise<void> {
|
||||
const pane = this.q(".react-flow__pane") ?? this.q(".react-flow__renderer");
|
||||
if (!pane) throw new Error("clickEmptyCanvas: pane not rendered");
|
||||
await this.user.click(pane);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click every resource that the fixture's relationships connect to a finding.
|
||||
* Findings are hidden by default in the full graph view (Tier 1) — clicking
|
||||
* their adjacent resources reveals them.
|
||||
*/
|
||||
async expandAllFindings(): Promise<void> {
|
||||
const findingIds = new Set(
|
||||
(this.fixture.queryResult?.nodes ?? [])
|
||||
.filter((n) =>
|
||||
n.labels.some((l) => l.toLowerCase().includes("finding")),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const resourceWithFindingIds = new Set<string>();
|
||||
for (const rel of this.fixture.queryResult?.relationships ?? []) {
|
||||
if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target);
|
||||
if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source);
|
||||
}
|
||||
for (const id of Array.from(resourceWithFindingIds)) {
|
||||
const el = this.getNodeById(id);
|
||||
if (el) {
|
||||
await this.user.click(el);
|
||||
await this.waitForTransition(50);
|
||||
if (this.hasNodeActionDialog) {
|
||||
await this.chooseShowFindingsAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hoverNode(nodeId: string): Promise<void> {
|
||||
const el = this.getNodeById(nodeId);
|
||||
if (!el) throw new Error(`hoverNode: node "${nodeId}" not found`);
|
||||
await this.user.hover(el);
|
||||
await this.waitForTransition(80);
|
||||
}
|
||||
|
||||
async hoverFirstResourceNode(): Promise<HTMLElement> {
|
||||
const [resource] = this.resourceNodes;
|
||||
if (!resource)
|
||||
throw new Error("hoverFirstResourceNode: no resource rendered");
|
||||
await this.user.hover(resource);
|
||||
await this.waitForTransition(80);
|
||||
return resource;
|
||||
}
|
||||
|
||||
async unhoverNodes(): Promise<void> {
|
||||
const canvas =
|
||||
this.q(".react-flow__pane") ?? this.q(".react-flow__renderer");
|
||||
if (canvas) await this.user.hover(canvas);
|
||||
await this.waitForTransition(80);
|
||||
}
|
||||
|
||||
async zoomIn(): Promise<void> {
|
||||
const btn = this.toolbar.zoomInButton;
|
||||
if (!btn) throw new Error("zoomIn: toolbar not rendered");
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async zoomOut(): Promise<void> {
|
||||
const btn = this.toolbar.zoomOutButton;
|
||||
if (!btn) throw new Error("zoomOut: toolbar not rendered");
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async fit(): Promise<void> {
|
||||
const btn = this.toolbar.fitButton;
|
||||
if (!btn) throw new Error("fit: toolbar not rendered");
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async closeNodeDetailsModal(): Promise<void> {
|
||||
const btn = this.q('button[aria-label="Close node details"]');
|
||||
if (!btn) throw new Error("closeNodeDetailsModal: modal not rendered");
|
||||
await this.user.click(btn);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseShowFindingsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /show findings/i.test(btn.textContent ?? ""));
|
||||
if (!button) throw new Error("chooseShowFindingsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseHideFindingsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /hide findings/i.test(btn.textContent ?? ""));
|
||||
if (!button) throw new Error("chooseHideFindingsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseViewNodeDetailsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /view node details/i.test(btn.textContent ?? ""));
|
||||
if (!button)
|
||||
throw new Error("chooseViewNodeDetailsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseBackToFullGraphAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /back to full graph/i.test(btn.textContent ?? ""));
|
||||
if (!button)
|
||||
throw new Error("chooseBackToFullGraphAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async exitFilteredView(): Promise<void> {
|
||||
const btn = this.toolbar.backToFullViewButton;
|
||||
if (!btn) throw new Error("exitFilteredView: not in filtered view");
|
||||
await this.user.click(btn);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async openFullscreen(): Promise<void> {
|
||||
const btn = this.toolbar.fullscreenButton;
|
||||
if (!btn) throw new Error("openFullscreen: button not found");
|
||||
await this.user.click(btn);
|
||||
await this.waitFor(() => this.fullscreenDialog, 3000);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async closeFullscreen(): Promise<void> {
|
||||
const dialog = this.fullscreenDialog;
|
||||
if (!dialog) return;
|
||||
const close = dialog.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Close"]',
|
||||
);
|
||||
if (close) await this.user.click(close);
|
||||
else await this.user.keyboard("{Escape}");
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async exportAsPNG(target: "main" | "fullscreen" = "main"): Promise<void> {
|
||||
const scope =
|
||||
target === "fullscreen" ? this.fullscreenDialog : this.container;
|
||||
if (!scope) throw new Error("exportAsPNG: target scope missing");
|
||||
const btn = scope.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Export graph"]',
|
||||
);
|
||||
if (!btn) throw new Error("exportAsPNG: export button disabled or missing");
|
||||
await this.user.click(btn);
|
||||
await this.waitForTransition(300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an export and capture the resulting download as a structured
|
||||
* record. Intercepts `HTMLAnchorElement.prototype.click` so the test
|
||||
* environment doesn't actually navigate, and parses width/height from the
|
||||
* PNG IHDR chunk so callers can assert on canvas size.
|
||||
*/
|
||||
async captureExportPNG(
|
||||
target: "main" | "fullscreen" = "main",
|
||||
timeoutMs = 10000,
|
||||
): Promise<{
|
||||
filename: string;
|
||||
href: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
const downloads: Array<{ href: string; download: string }> = [];
|
||||
const originalClick = HTMLAnchorElement.prototype.click;
|
||||
HTMLAnchorElement.prototype.click = function () {
|
||||
if (this.download) {
|
||||
downloads.push({ href: this.href, download: this.download });
|
||||
return;
|
||||
}
|
||||
originalClick.call(this);
|
||||
};
|
||||
|
||||
try {
|
||||
await this.exportAsPNG(target);
|
||||
await this.waitFor(() => downloads.length > 0, timeoutMs);
|
||||
} finally {
|
||||
HTMLAnchorElement.prototype.click = originalClick;
|
||||
}
|
||||
|
||||
const [download] = downloads;
|
||||
const [meta, base64 = ""] = download.href.split(",");
|
||||
const mimeType = /^data:([^;]+)/.exec(meta ?? "")?.[1] ?? "";
|
||||
|
||||
// PNG IHDR chunk: bytes 16-19 = width (uint32 BE), 20-23 = height.
|
||||
const bytes = atob(base64);
|
||||
const u32BE = (offset: number) =>
|
||||
((bytes.charCodeAt(offset) << 24) |
|
||||
(bytes.charCodeAt(offset + 1) << 16) |
|
||||
(bytes.charCodeAt(offset + 2) << 8) |
|
||||
bytes.charCodeAt(offset + 3)) >>>
|
||||
0;
|
||||
|
||||
return {
|
||||
filename: download.download,
|
||||
href: download.href,
|
||||
mimeType,
|
||||
width: u32BE(16),
|
||||
height: u32BE(20),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
|
||||
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
@@ -22,17 +22,16 @@ import {
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/shadcn/dialog";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { Modal } from "@/components/shadcn/modal/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -48,17 +47,40 @@ import {
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
NodeDetailPanel as NodeDetailDrawer,
|
||||
QueryDescription,
|
||||
QueryExecutionError,
|
||||
QueryParametersForm,
|
||||
QuerySelector,
|
||||
ScanListTable,
|
||||
} from "./_components";
|
||||
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
|
||||
import type { GraphHandle } from "./_components/graph/attack-path-graph";
|
||||
import { useGraphState } from "./_hooks/use-graph-state";
|
||||
import { useQueryBuilder } from "./_hooks/use-query-builder";
|
||||
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
|
||||
import { exportGraphAsPNG } from "./_lib";
|
||||
|
||||
const NODE_ACTION_CONTEXT = {
|
||||
RESOURCE_FINDINGS: "resource-findings",
|
||||
FILTERED_PARENT: "filtered-parent",
|
||||
} as const;
|
||||
|
||||
type NodeActionContext =
|
||||
(typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT];
|
||||
|
||||
interface NodeActionBase {
|
||||
node: GraphNode;
|
||||
context: NodeActionContext;
|
||||
}
|
||||
|
||||
interface ResourceFindingsNodeAction extends NodeActionBase {
|
||||
context: typeof NODE_ACTION_CONTEXT.RESOURCE_FINDINGS;
|
||||
}
|
||||
|
||||
interface FilteredParentNodeAction extends NodeActionBase {
|
||||
context: typeof NODE_ACTION_CONTEXT.FILTERED_PARENT;
|
||||
}
|
||||
|
||||
type NodeActionState = ResourceFindingsNodeAction | FilteredParentNodeAction;
|
||||
|
||||
/**
|
||||
* Attack Paths
|
||||
@@ -76,10 +98,10 @@ export default function AttackPathsPage() {
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<AttackPathGraphRef>(null);
|
||||
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
|
||||
const [nodeAction, setNodeAction] = useState<NodeActionState | null>(null);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
const fullscreenGraphRef = useRef<GraphHandle>(null);
|
||||
const hasResetRef = useRef(false);
|
||||
const nodeDetailsRef = useRef<HTMLDivElement>(null);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
@@ -95,6 +117,11 @@ export default function AttackPathsPage() {
|
||||
}
|
||||
}, [graphState]);
|
||||
|
||||
// Reset graph state when scan changes
|
||||
useEffect(() => {
|
||||
graphState.resetGraph();
|
||||
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
|
||||
|
||||
// Load available scans on mount
|
||||
useEffect(() => {
|
||||
const loadScans = async () => {
|
||||
@@ -189,10 +216,6 @@ export default function AttackPathsPage() {
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
|
||||
const handleQueryChange = (queryId: string) => {
|
||||
queryBuilder.handleQueryChange(queryId);
|
||||
};
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
title,
|
||||
@@ -289,22 +312,44 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
// Enter filtered view showing only paths containing this node
|
||||
graphState.enterFilteredView(node.id);
|
||||
|
||||
// For findings, also scroll to the details section
|
||||
const isFinding = node.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
|
||||
if (isFinding) {
|
||||
setTimeout(() => {
|
||||
nodeDetailsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, 100);
|
||||
// Findings skip the intermediate node-details modal. The finding drawer
|
||||
// is the useful destination, so open it directly from the graph click.
|
||||
graphState.enterFilteredView(node.id);
|
||||
// enterFilteredView stores the filtered node as selected so the graph can
|
||||
// highlight it. Clear the selection right after for findings so the node
|
||||
// details modal does not open before the finding drawer.
|
||||
graphState.selectNode(null);
|
||||
handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphState.isFilteredView) {
|
||||
setNodeAction({ node, context: NODE_ACTION_CONTEXT.FILTERED_PARENT });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceData = graphState.fullData || graphState.data;
|
||||
const hasFindings = sourceData?.edges?.some((edge) => {
|
||||
if (edge.source !== node.id && edge.target !== node.id) return false;
|
||||
const otherId = edge.source === node.id ? edge.target : edge.source;
|
||||
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
|
||||
return otherNode?.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
);
|
||||
});
|
||||
|
||||
if (hasFindings) {
|
||||
setNodeAction({ node, context: NODE_ACTION_CONTEXT.RESOURCE_FINDINGS });
|
||||
return;
|
||||
}
|
||||
|
||||
finding.resetFindingDetails();
|
||||
graphState.selectNode(node.id);
|
||||
};
|
||||
|
||||
const handleBackToFullView = () => {
|
||||
@@ -315,33 +360,53 @@ export default function AttackPathsPage() {
|
||||
graphState.selectNode(null);
|
||||
};
|
||||
|
||||
const getFindingId = (node: GraphNode | null) =>
|
||||
node ? String(node.properties?.id || node.id) : "";
|
||||
const handleShowNodeFindings = () => {
|
||||
if (!nodeAction) return;
|
||||
graphState.toggleExpandedResource(nodeAction.node.id);
|
||||
setNodeAction(null);
|
||||
};
|
||||
|
||||
const handleOpenNodeDetails = () => {
|
||||
if (!nodeAction) return;
|
||||
finding.resetFindingDetails();
|
||||
graphState.selectNode(nodeAction.node.id);
|
||||
setNodeAction(null);
|
||||
};
|
||||
|
||||
const handleReturnToFullGraph = () => {
|
||||
graphState.exitFilteredView();
|
||||
graphState.selectNode(null);
|
||||
setNodeAction(null);
|
||||
};
|
||||
|
||||
const handleViewFinding = (findingId: string) => {
|
||||
if (!findingId) return;
|
||||
void finding.navigateToFinding(findingId);
|
||||
};
|
||||
|
||||
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
|
||||
const actionNodeFindingsExpanded = nodeAction
|
||||
? graphState.expandedResources.has(nodeAction.node.id)
|
||||
: false;
|
||||
|
||||
const handleGraphExport = async (target: "main" | "fullscreen") => {
|
||||
const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef;
|
||||
const handle = ref.current;
|
||||
if (!handle) return;
|
||||
|
||||
try {
|
||||
if (svgElement) {
|
||||
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Graph exported as SVG",
|
||||
variant: "default",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Could not find graph element");
|
||||
}
|
||||
} catch (error) {
|
||||
await exportGraphAsPNG(
|
||||
handle.getContainerElement(),
|
||||
handle.getNodesBounds(),
|
||||
);
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to export graph",
|
||||
variant: "destructive",
|
||||
title: "Success",
|
||||
description: "Graph exported",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
const description =
|
||||
error instanceof Error ? error.message : "Failed to export graph";
|
||||
showErrorToast("Export failed", description);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -423,7 +488,7 @@ export default function AttackPathsPage() {
|
||||
<QuerySelector
|
||||
queries={queries}
|
||||
selectedQueryId={queryBuilder.selectedQuery}
|
||||
onQueryChange={handleQueryChange}
|
||||
onQueryChange={queryBuilder.handleQueryChange}
|
||||
/>
|
||||
|
||||
{queryBuilder.selectedQueryData && (
|
||||
@@ -524,11 +589,7 @@ export default function AttackPathsPage() {
|
||||
onZoomIn={() => graphRef.current?.zoomIn()}
|
||||
onZoomOut={() => graphRef.current?.zoomOut()}
|
||||
onFitToScreen={() => graphRef.current?.resetZoom()}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
graphRef.current?.getSVGElement() || null,
|
||||
)
|
||||
}
|
||||
onExport={() => handleGraphExport("main")}
|
||||
/>
|
||||
|
||||
{/* Fullscreen button */}
|
||||
@@ -550,6 +611,10 @@ export default function AttackPathsPage() {
|
||||
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 rounded-none border-0 p-0 sm:max-w-full">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Fullscreen graph view</DialogTitle>
|
||||
<DialogDescription>
|
||||
Explore the attack path graph at full size. Use
|
||||
the toolbar to zoom, fit, or export the graph.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
||||
<GraphControls
|
||||
@@ -562,15 +627,10 @@ export default function AttackPathsPage() {
|
||||
onFitToScreen={() =>
|
||||
fullscreenGraphRef.current?.resetZoom()
|
||||
}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
fullscreenGraphRef.current?.getSVGElement() ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
onExport={() => handleGraphExport("fullscreen")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6 lg:flex-row">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<AttackPathGraph
|
||||
ref={fullscreenGraphRef}
|
||||
@@ -578,64 +638,11 @@ export default function AttackPathsPage() {
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={
|
||||
graphState.expandedResources
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
{graphState.selectedNode && (
|
||||
<section aria-labelledby="node-details-heading">
|
||||
<Card className="w-96 overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3
|
||||
id="node-details-heading"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
Node Details
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary mb-4 text-xs">
|
||||
{graphState.selectedNode?.labels.some(
|
||||
(label) =>
|
||||
label
|
||||
.toLowerCase()
|
||||
.includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode?.properties
|
||||
?.check_title ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode?.properties
|
||||
?.name ||
|
||||
graphState.selectedNode?.properties
|
||||
?.id ||
|
||||
"Unknown Resource"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold">
|
||||
Type
|
||||
</h4>
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
{graphState.selectedNode?.labels
|
||||
.map(formatNodeLabel)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -654,11 +661,12 @@ export default function AttackPathsPage() {
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={graphState.expandedResources}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend below */}
|
||||
<div className="hidden justify-center lg:flex">
|
||||
<div className="flex justify-center overflow-x-auto">
|
||||
<GraphLegend data={graphState.data} />
|
||||
</div>
|
||||
</>
|
||||
@@ -666,68 +674,48 @@ export default function AttackPathsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Detail Panel - Below Graph */}
|
||||
{graphState.selectedNode && graphState.data && (
|
||||
<div
|
||||
ref={nodeDetailsRef}
|
||||
className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Node Details</h3>
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm">
|
||||
{String(
|
||||
graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
)
|
||||
? graphState.selectedNode.properties?.check_title ||
|
||||
graphState.selectedNode.properties?.id ||
|
||||
"Unknown Finding"
|
||||
: graphState.selectedNode.properties?.name ||
|
||||
graphState.selectedNode.properties?.id ||
|
||||
"Unknown Resource",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
) && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleViewFinding(getFindingId(graphState.selectedNode))
|
||||
}
|
||||
disabled={finding.findingDetailLoading}
|
||||
aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
|
||||
>
|
||||
{finding.findingDetailLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Finding"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Node Detail Drawer */}
|
||||
{graphState.data && (
|
||||
<NodeDetailDrawer
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onClose={handleCloseDetails}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NodeDetailContent
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
/>
|
||||
</div>
|
||||
{nodeAction && (
|
||||
<Modal
|
||||
open={!!nodeAction}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNodeAction(null);
|
||||
}}
|
||||
size="md"
|
||||
title="Choose node action"
|
||||
description={
|
||||
nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT
|
||||
? "You're viewing a filtered path. Choose whether to return to the full graph or inspect this node."
|
||||
: "This node has related findings. Choose whether to reveal them in the graph or inspect the node metadata."
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={handleOpenNodeDetails}>
|
||||
View node details
|
||||
</Button>
|
||||
{nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
|
||||
<Button onClick={handleReturnToFullGraph}>
|
||||
Back to full graph
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleShowNodeFindings}>
|
||||
{actionNodeFindingsExpanded
|
||||
? "Hide findings"
|
||||
: "Show findings"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{finding.findingDetails && (
|
||||
|
||||
@@ -15,7 +15,7 @@ export function Navbar({ title, icon }: NavbarProps) {
|
||||
title={title}
|
||||
icon={icon}
|
||||
feedsSlot={
|
||||
<Suspense fallback={<FeedsLoadingFallback />}>
|
||||
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
|
||||
<FeedsServer limit={15} />
|
||||
</Suspense>
|
||||
}
|
||||
|
||||
+62
-22
@@ -55,6 +55,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-03-26T08:39:34.728Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@dagrejs/dagre",
|
||||
"from": "3.0.0",
|
||||
"to": "3.0.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-16T12:15:51.357Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@extractus/feed-extractor",
|
||||
@@ -133,7 +141,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -367,14 +375,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@types/dagre",
|
||||
"from": "0.7.53",
|
||||
"to": "0.7.53",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-11-27T11:47:22.908Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@types/js-yaml",
|
||||
@@ -391,6 +391,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-03-26T08:39:34.728Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@xyflow/react",
|
||||
"from": "12.10.2",
|
||||
"to": "12.10.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-16T12:15:51.357Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "ai",
|
||||
@@ -447,14 +455,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "dagre",
|
||||
"from": "0.8.5",
|
||||
"to": "0.8.5",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-11-27T11:47:22.908Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "date-fns",
|
||||
@@ -535,6 +535,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "modern-screenshot",
|
||||
"from": "4.7.0",
|
||||
"to": "4.7.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-05T07:21:33.657Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "nanoid",
|
||||
@@ -549,7 +557,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -581,7 +589,7 @@
|
||||
"from": "19.2.4",
|
||||
"to": "19.2.5",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -597,7 +605,7 @@
|
||||
"from": "19.2.4",
|
||||
"to": "19.2.5",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -765,7 +773,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -887,6 +895,22 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-29T16:42:27.795Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser",
|
||||
"from": "4.0.18",
|
||||
"to": "4.0.18",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-30T13:13:39.682Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser-playwright",
|
||||
"from": "4.0.18",
|
||||
"to": "4.0.18",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-30T13:13:39.682Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/coverage-v8",
|
||||
@@ -933,7 +957,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -1039,6 +1063,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-10T11:55:26.693Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "msw",
|
||||
"from": "2.13.4",
|
||||
"to": "2.13.4",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-30T13:13:39.682Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "postcss",
|
||||
@@ -1102,5 +1134,13 @@
|
||||
"to": "4.0.18",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-29T16:42:27.795Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "vitest-browser-react",
|
||||
"from": "2.0.4",
|
||||
"to": "2.0.4",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-30T13:23:20.132Z"
|
||||
}
|
||||
]
|
||||
|
||||
+19
-6
@@ -16,8 +16,11 @@
|
||||
"lint:knip:fix": "knip --fix --max-issues 504",
|
||||
"format:check": "./node_modules/.bin/prettier --check ./app",
|
||||
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run --project unit",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:browser:watch": "vitest --project browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans",
|
||||
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui",
|
||||
@@ -35,6 +38,7 @@
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.40.0",
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@extractus/feed-extractor": "7.1.7",
|
||||
"@heroui/react": "2.8.4",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
@@ -74,9 +78,9 @@
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@types/dagre": "0.7.53",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@uiw/react-codemirror": "4.25.8",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"ai": "5.0.109",
|
||||
"alert": "6.0.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -84,7 +88,6 @@
|
||||
"cmdk": "1.1.1",
|
||||
"codemirror": "6.0.2",
|
||||
"d3": "7.9.0",
|
||||
"dagre": "0.8.5",
|
||||
"date-fns": "4.1.0",
|
||||
"framer-motion": "11.18.2",
|
||||
"import-in-the-middle": "2.0.0",
|
||||
@@ -95,6 +98,7 @@
|
||||
"langchain": "1.2.10",
|
||||
"lucide-react": "0.543.0",
|
||||
"marked": "15.0.12",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"nanoid": "5.1.6",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
@@ -141,6 +145,8 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
@@ -160,6 +166,7 @@
|
||||
"globals": "17.0.0",
|
||||
"jsdom": "27.4.0",
|
||||
"knip": "6.3.1",
|
||||
"msw": "2.13.4",
|
||||
"postcss": "8.4.38",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
@@ -167,7 +174,8 @@
|
||||
"tailwind-variants": "0.1.20",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.0.18",
|
||||
"vitest-browser-react": "2.0.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
@@ -196,5 +204,10 @@
|
||||
}
|
||||
},
|
||||
"version": "0.0.1",
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+253
-131
@@ -53,6 +53,9 @@ importers:
|
||||
'@codemirror/view':
|
||||
specifier: 6.40.0
|
||||
version: 6.40.0
|
||||
'@dagrejs/dagre':
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
'@extractus/feed-extractor':
|
||||
specifier: 7.1.7
|
||||
version: 7.1.7
|
||||
@@ -170,15 +173,15 @@ importers:
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@types/dagre':
|
||||
specifier: 0.7.53
|
||||
version: 0.7.53
|
||||
'@types/js-yaml':
|
||||
specifier: 4.0.9
|
||||
version: 4.0.9
|
||||
'@uiw/react-codemirror':
|
||||
specifier: 4.25.8
|
||||
version: 4.25.8(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@xyflow/react':
|
||||
specifier: 12.10.2
|
||||
version: 12.10.2(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
ai:
|
||||
specifier: 5.0.109
|
||||
version: 5.0.109(zod@4.1.11)
|
||||
@@ -200,9 +203,6 @@ importers:
|
||||
d3:
|
||||
specifier: 7.9.0
|
||||
version: 7.9.0
|
||||
dagre:
|
||||
specifier: 0.8.5
|
||||
version: 0.8.5
|
||||
date-fns:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0
|
||||
@@ -233,6 +233,9 @@ importers:
|
||||
marked:
|
||||
specifier: 15.0.12
|
||||
version: 15.0.12
|
||||
modern-screenshot:
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0
|
||||
nanoid:
|
||||
specifier: 5.1.6
|
||||
version: 5.1.6
|
||||
@@ -366,9 +369,15 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
'@vitest/browser':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
|
||||
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
|
||||
autoprefixer:
|
||||
specifier: 10.4.19
|
||||
version: 10.4.19(postcss@8.4.38)
|
||||
@@ -423,6 +432,9 @@ importers:
|
||||
knip:
|
||||
specifier: 6.3.1
|
||||
version: 6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
||||
msw:
|
||||
specifier: 2.13.4
|
||||
version: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
postcss:
|
||||
specifier: 8.4.38
|
||||
version: 8.4.38
|
||||
@@ -446,7 +458,10 @@ importers:
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
vitest-browser-react:
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.0.18)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -993,6 +1008,12 @@ packages:
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@dagrejs/dagre@3.0.0':
|
||||
resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==}
|
||||
|
||||
'@dagrejs/graphlib@4.0.1':
|
||||
resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
@@ -2113,35 +2134,35 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/ansi@1.0.2':
|
||||
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
|
||||
engines: {node: '>=18'}
|
||||
'@inquirer/ansi@2.0.5':
|
||||
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
|
||||
'@inquirer/confirm@5.1.21':
|
||||
resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
|
||||
engines: {node: '>=18'}
|
||||
'@inquirer/confirm@6.0.12':
|
||||
resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@10.3.2':
|
||||
resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
|
||||
engines: {node: '>=18'}
|
||||
'@inquirer/core@11.1.9':
|
||||
resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@1.0.15':
|
||||
resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
|
||||
engines: {node: '>=18'}
|
||||
'@inquirer/figures@2.0.5':
|
||||
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
|
||||
'@inquirer/type@3.0.10':
|
||||
resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
|
||||
engines: {node: '>=18'}
|
||||
'@inquirer/type@4.0.5':
|
||||
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
@@ -2373,6 +2394,9 @@ packages:
|
||||
'@open-draft/deferred-promise@2.2.0':
|
||||
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
|
||||
|
||||
'@open-draft/deferred-promise@3.0.0':
|
||||
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
|
||||
|
||||
'@open-draft/logger@0.3.0':
|
||||
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
|
||||
|
||||
@@ -5180,9 +5204,6 @@ packages:
|
||||
'@types/d3@7.4.3':
|
||||
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
|
||||
|
||||
'@types/dagre@0.7.53':
|
||||
resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==}
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
@@ -5245,6 +5266,9 @@ packages:
|
||||
'@types/react@19.2.8':
|
||||
resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
|
||||
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
@@ -5569,6 +5593,15 @@ packages:
|
||||
'@xtuc/long@4.2.2':
|
||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||
|
||||
'@xyflow/react@12.10.2':
|
||||
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5890,6 +5923,9 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6210,9 +6246,6 @@ packages:
|
||||
dagre-d3-es@7.0.13:
|
||||
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
|
||||
|
||||
dagre@0.8.5:
|
||||
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
|
||||
|
||||
damerau-levenshtein@1.0.8:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
@@ -6736,9 +6769,18 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-wrap-ansi@0.2.0:
|
||||
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
|
||||
|
||||
fast-xml-parser@5.3.8:
|
||||
resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==}
|
||||
hasBin: true
|
||||
@@ -6956,11 +6998,8 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
graphlib@2.1.8:
|
||||
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
|
||||
|
||||
graphql@16.12.0:
|
||||
resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
|
||||
graphql@16.13.2:
|
||||
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
|
||||
hachure-fill@0.5.2:
|
||||
@@ -7036,8 +7075,8 @@ packages:
|
||||
hastscript@9.0.1:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
headers-polyfill@4.0.3:
|
||||
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
|
||||
headers-polyfill@5.0.1:
|
||||
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
@@ -7934,6 +7973,9 @@ packages:
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
|
||||
modern-screenshot@4.7.0:
|
||||
resolution: {integrity: sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==}
|
||||
|
||||
module-details-from-path@1.0.4:
|
||||
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
||||
|
||||
@@ -7950,8 +7992,8 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
msw@2.12.14:
|
||||
resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==}
|
||||
msw@2.13.4:
|
||||
resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -7964,9 +8006,9 @@ packages:
|
||||
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||
hasBin: true
|
||||
|
||||
mute-stream@2.0.0:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
mute-stream@3.0.0:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
@@ -8733,8 +8775,8 @@ packages:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
rettime@0.10.1:
|
||||
resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
|
||||
rettime@0.11.8:
|
||||
resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
@@ -8821,6 +8863,9 @@ packages:
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-cookie-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9159,6 +9204,10 @@ packages:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.0.19:
|
||||
resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
|
||||
|
||||
@@ -9186,6 +9235,10 @@ packages:
|
||||
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
@@ -9233,8 +9286,8 @@ packages:
|
||||
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
type-fest@5.4.1:
|
||||
resolution: {integrity: sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==}
|
||||
type-fest@5.6.0:
|
||||
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
type-is@2.0.1:
|
||||
@@ -9479,6 +9532,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest-browser-react@2.0.4:
|
||||
resolution: {integrity: sha512-FQq2z519Bwp/rANaQXU+ox7M4d0q/bTQkF2pgwRAehE+pqJ6myYOLp+P2Dy2kuk+K4IQJHMyijMCSQ1da/xW8w==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
vitest: ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18:
|
||||
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
@@ -9632,10 +9699,6 @@ packages:
|
||||
world-atlas@2.0.2:
|
||||
resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -9698,10 +9761,6 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yoctocolors-cjs@2.1.3:
|
||||
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yoctocolors@2.1.2:
|
||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -9723,6 +9782,21 @@ packages:
|
||||
zod@4.1.11:
|
||||
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
zustand@5.0.8:
|
||||
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -10932,6 +11006,12 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@dagrejs/dagre@3.0.0':
|
||||
dependencies:
|
||||
'@dagrejs/graphlib': 4.0.1
|
||||
|
||||
'@dagrejs/graphlib@4.0.1': {}
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@dotenvx/dotenvx@1.51.4':
|
||||
@@ -12371,31 +12451,30 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/ansi@1.0.2': {}
|
||||
'@inquirer/ansi@2.0.5': {}
|
||||
|
||||
'@inquirer/confirm@5.1.21(@types/node@24.10.8)':
|
||||
'@inquirer/confirm@6.0.12(@types/node@24.10.8)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2(@types/node@24.10.8)
|
||||
'@inquirer/type': 3.0.10(@types/node@24.10.8)
|
||||
'@inquirer/core': 11.1.9(@types/node@24.10.8)
|
||||
'@inquirer/type': 4.0.5(@types/node@24.10.8)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.8
|
||||
|
||||
'@inquirer/core@10.3.2(@types/node@24.10.8)':
|
||||
'@inquirer/core@11.1.9(@types/node@24.10.8)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.2
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 3.0.10(@types/node@24.10.8)
|
||||
'@inquirer/ansi': 2.0.5
|
||||
'@inquirer/figures': 2.0.5
|
||||
'@inquirer/type': 4.0.5(@types/node@24.10.8)
|
||||
cli-width: 4.1.0
|
||||
mute-stream: 2.0.0
|
||||
fast-wrap-ansi: 0.2.0
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
wrap-ansi: 6.2.0
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.8
|
||||
|
||||
'@inquirer/figures@1.0.15': {}
|
||||
'@inquirer/figures@2.0.5': {}
|
||||
|
||||
'@inquirer/type@3.0.10(@types/node@24.10.8)':
|
||||
'@inquirer/type@4.0.5(@types/node@24.10.8)':
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.8
|
||||
|
||||
@@ -12676,6 +12755,8 @@ snapshots:
|
||||
|
||||
'@open-draft/deferred-promise@2.2.0': {}
|
||||
|
||||
'@open-draft/deferred-promise@3.0.0': {}
|
||||
|
||||
'@open-draft/logger@0.3.0':
|
||||
dependencies:
|
||||
is-node-process: 1.2.0
|
||||
@@ -13062,8 +13143,7 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.56.1
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
optional: true
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
|
||||
dependencies:
|
||||
@@ -16144,8 +16224,6 @@ snapshots:
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
|
||||
'@types/dagre@0.7.53': {}
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -16214,6 +16292,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
dependencies:
|
||||
'@types/node': 24.10.8
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
@@ -16433,39 +16515,37 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/browser-playwright@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
|
||||
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
playwright: 1.56.1
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
optional: true
|
||||
|
||||
'@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
|
||||
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
|
||||
dependencies:
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
'@vitest/utils': 4.0.18
|
||||
magic-string: 0.30.21
|
||||
pixelmatch: 7.1.0
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
|
||||
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
@@ -16477,9 +16557,9 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
dependencies:
|
||||
@@ -16488,20 +16568,20 @@ snapshots:
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4)
|
||||
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
vite: 7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
dependencies:
|
||||
@@ -16519,7 +16599,7 @@ snapshots:
|
||||
'@vitest/utils@4.0.18':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
tinyrainbow: 3.0.3
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
dependencies:
|
||||
@@ -16601,6 +16681,29 @@ snapshots:
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
||||
'@xyflow/react@12.10.2(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.76
|
||||
classcat: 5.0.5
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
zustand: 4.5.7(@types/react@19.2.8)(react@19.2.5)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -16943,6 +17046,8 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
@@ -17279,11 +17384,6 @@ snapshots:
|
||||
d3: 7.9.0
|
||||
lodash-es: 4.17.23
|
||||
|
||||
dagre@0.8.5:
|
||||
dependencies:
|
||||
graphlib: 2.1.8
|
||||
lodash: 4.17.23
|
||||
|
||||
damerau-levenshtein@1.0.8: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
@@ -17969,8 +18069,18 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 3.0.3
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-wrap-ansi@0.2.0:
|
||||
dependencies:
|
||||
fast-string-width: 3.0.2
|
||||
|
||||
fast-xml-parser@5.3.8:
|
||||
dependencies:
|
||||
strnum: 2.1.2
|
||||
@@ -18177,11 +18287,7 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graphlib@2.1.8:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
|
||||
graphql@16.12.0: {}
|
||||
graphql@16.13.2: {}
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
@@ -18329,7 +18435,10 @@ snapshots:
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
headers-polyfill@4.0.3: {}
|
||||
headers-polyfill@5.0.1:
|
||||
dependencies:
|
||||
'@types/set-cookie-parser': 2.4.10
|
||||
set-cookie-parser: 3.1.0
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
@@ -19431,6 +19540,8 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
modern-screenshot@4.7.0: {}
|
||||
|
||||
module-details-from-path@1.0.4: {}
|
||||
|
||||
motion-dom@11.18.1:
|
||||
@@ -19439,29 +19550,28 @@ snapshots:
|
||||
|
||||
motion-utils@11.18.1: {}
|
||||
|
||||
mrmime@2.0.1:
|
||||
optional: true
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4):
|
||||
msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 5.1.21(@types/node@24.10.8)
|
||||
'@inquirer/confirm': 6.0.12(@types/node@24.10.8)
|
||||
'@mswjs/interceptors': 0.41.3
|
||||
'@open-draft/deferred-promise': 2.2.0
|
||||
'@open-draft/deferred-promise': 3.0.0
|
||||
'@types/statuses': 2.0.6
|
||||
cookie: 1.1.1
|
||||
graphql: 16.12.0
|
||||
headers-polyfill: 4.0.3
|
||||
graphql: 16.13.2
|
||||
headers-polyfill: 5.0.1
|
||||
is-node-process: 1.2.0
|
||||
outvariant: 1.4.3
|
||||
path-to-regexp: 6.3.0
|
||||
picocolors: 1.1.1
|
||||
rettime: 0.10.1
|
||||
rettime: 0.11.8
|
||||
statuses: 2.0.2
|
||||
strict-event-emitter: 0.5.1
|
||||
tough-cookie: 6.0.0
|
||||
type-fest: 5.4.1
|
||||
tough-cookie: 6.0.1
|
||||
type-fest: 5.6.0
|
||||
until-async: 3.0.2
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
@@ -19471,7 +19581,7 @@ snapshots:
|
||||
|
||||
mustache@4.2.0: {}
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
@@ -19831,7 +19941,6 @@ snapshots:
|
||||
pixelmatch@7.1.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
optional: true
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
@@ -19849,8 +19958,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pngjs@7.0.0:
|
||||
optional: true
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
@@ -20321,7 +20429,7 @@ snapshots:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
rettime@0.10.1: {}
|
||||
rettime@0.11.8: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
@@ -20458,6 +20566,8 @@ snapshots:
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-cookie-parser@3.1.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -20504,7 +20614,7 @@ snapshots:
|
||||
fuzzysort: 3.1.0
|
||||
https-proxy-agent: 7.0.6
|
||||
kleur: 4.1.5
|
||||
msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4)
|
||||
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
node-fetch: 3.3.2
|
||||
open: 11.0.0
|
||||
ora: 8.2.0
|
||||
@@ -20646,7 +20756,6 @@ snapshots:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
optional: true
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
@@ -20917,6 +21026,8 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tldts-core@7.0.19: {}
|
||||
|
||||
tldts@7.0.19:
|
||||
@@ -20933,13 +21044,16 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
|
||||
totalist@3.0.1:
|
||||
optional: true
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tough-cookie@6.0.0:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@6.0.0:
|
||||
@@ -20984,7 +21098,7 @@ snapshots:
|
||||
|
||||
type-fest@0.7.1: {}
|
||||
|
||||
type-fest@5.4.1:
|
||||
type-fest@5.6.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
@@ -21258,10 +21372,19 @@ snapshots:
|
||||
terser: 5.46.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2):
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.0.18):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
@@ -21283,7 +21406,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 24.10.8
|
||||
'@vitest/browser-playwright': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
|
||||
jsdom: 27.4.0(@noble/hashes@1.8.0)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
@@ -21444,12 +21567,6 @@ snapshots:
|
||||
|
||||
world-atlas@2.0.2: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -21497,8 +21614,6 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
@@ -21517,6 +21632,13 @@ snapshots:
|
||||
|
||||
zod@4.1.11: {}
|
||||
|
||||
zustand@4.5.7(@types/react@19.2.8)(react@19.2.5):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
react: 19.2.5
|
||||
|
||||
zustand@5.0.8(@types/react@19.2.8)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
|
||||
@@ -26,6 +26,8 @@ onlyBuiltDependencies:
|
||||
- "@heroui/shared-utils"
|
||||
# unrs-resolver: Rust module resolver (NAPI-RS). Verifies the correct native binding is available for the platform.
|
||||
- unrs-resolver
|
||||
# msw: Copies mockServiceWorker.js into the directories listed in package.json's `msw.workerDirectory` (here: `public/`) so the runtime worker stays in sync with the installed msw version. Pure file copy — no native binary, no network access. Required for vitest browser tests to intercept fetches via the service worker.
|
||||
- msw
|
||||
|
||||
# --- Level 3: Trust Policy + Exotic Subdeps ---
|
||||
# Fail when a package's trust evidence is downgraded (e.g., new publisher).
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.13.4'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -192,8 +192,8 @@ export interface GraphNode {
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string | object;
|
||||
target: string | object;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
properties?: GraphNodeProperties;
|
||||
}
|
||||
@@ -232,40 +232,6 @@ export interface AttackPathQueryError {
|
||||
status: number;
|
||||
}
|
||||
|
||||
// Finding severity and status constants
|
||||
export const FINDING_SEVERITIES = {
|
||||
CRITICAL: "critical",
|
||||
HIGH: "high",
|
||||
MEDIUM: "medium",
|
||||
LOW: "low",
|
||||
INFO: "info",
|
||||
} as const;
|
||||
|
||||
type FindingSeverity =
|
||||
(typeof FINDING_SEVERITIES)[keyof typeof FINDING_SEVERITIES];
|
||||
|
||||
export const FINDING_STATUSES = {
|
||||
PASS: "PASS",
|
||||
FAIL: "FAIL",
|
||||
MANUAL: "MANUAL",
|
||||
} as const;
|
||||
|
||||
type FindingStatus = (typeof FINDING_STATUSES)[keyof typeof FINDING_STATUSES];
|
||||
|
||||
export interface RelatedFinding {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: FindingSeverity;
|
||||
status: FindingStatus;
|
||||
}
|
||||
|
||||
// Node Detail Types
|
||||
export interface NodeDetailData extends GraphNode {
|
||||
relatedFindings?: RelatedFinding[];
|
||||
incomingEdges?: GraphEdge[];
|
||||
outgoingEdges?: GraphEdge[];
|
||||
}
|
||||
|
||||
// Wizard State Types
|
||||
export interface WizardState {
|
||||
currentStep: 1 | 2;
|
||||
@@ -280,9 +246,6 @@ export interface GraphState {
|
||||
selectedNodeId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
zoomLevel: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
// Provider Integration
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Global stylesheet (Tailwind + design tokens) is imported by the Next.js
|
||||
// layouts in the real app. Tests render the page in isolation, bypassing the
|
||||
// layout, so without this import Tailwind classes resolve to nothing — the
|
||||
// page collapses to unstyled HTML and stacked elements end up overlapping
|
||||
// the graph nodes, blocking Playwright clicks. Pull the stylesheet directly
|
||||
// so the test bundle gets the same CSS the production page receives.
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { afterAll, afterEach, beforeAll, vi } from "vitest";
|
||||
|
||||
import { worker } from "./__tests__/msw/worker";
|
||||
|
||||
// Server Actions ("use server") are bundled by Vite as plain async functions
|
||||
// — the directive is a Next.js compiler concept, not part of Vite. When the
|
||||
// page invokes one, it runs in the browser and reaches `auth()` from
|
||||
// next-auth, which calls `next/headers` (request-scoped AsyncLocalStorage
|
||||
// only set up by Next's request handler) and throws "headers was called
|
||||
// outside a request scope". That kills every action before it can hit
|
||||
// MSW. Stub `auth.config` with a fake session so the action proceeds to
|
||||
// `fetch()` and MSW takes over.
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
handlers: {},
|
||||
}));
|
||||
|
||||
// Next.js's App Router context (`useRouter`, `useSearchParams`, `usePathname`)
|
||||
// is not available in vitest browser — there's no Next runtime mounting the
|
||||
// providers. We back the hooks with the real `window.location` so navigating
|
||||
// via `history.replaceState` in tests is enough to drive the page.
|
||||
vi.mock("next/navigation", () => {
|
||||
const router = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
return {
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
useRouter: () => router,
|
||||
usePathname: () => window.location.pathname,
|
||||
useParams: () => ({}),
|
||||
redirect: vi.fn(),
|
||||
notFound: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await worker.start({
|
||||
serviceWorker: { url: "/mockServiceWorker.js" },
|
||||
onUnhandledRequest: "error",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
worker.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
worker.stop();
|
||||
});
|
||||
|
||||
// React Flow's pan/drag handlers dispatch pointer events that access
|
||||
// `event.view.document` on the node. When user-event synthesises these
|
||||
// events the `view` property can be null, producing harmless
|
||||
// "Cannot read properties of null (reading 'document')" errors.
|
||||
// Swallow only that specific unhandled error; everything else propagates.
|
||||
const isReactFlowNullViewError = (reason: unknown): boolean => {
|
||||
const message =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === "string"
|
||||
? reason
|
||||
: "";
|
||||
return message.includes(
|
||||
"Cannot read properties of null (reading 'document')",
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
if (isReactFlowNullViewError(event.error)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (isReactFlowNullViewError(event.reason)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
+173
-32
@@ -1,39 +1,180 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import path from "path";
|
||||
import type { TestProjectConfiguration } from "vitest/config";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
restoreMocks: true,
|
||||
mockReset: true,
|
||||
unstubEnvs: true,
|
||||
unstubGlobals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["**/*.test.{ts,tsx}"],
|
||||
exclude: [
|
||||
"node_modules",
|
||||
".next",
|
||||
"tests/**/*", // Playwright E2E tests
|
||||
],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
"node_modules",
|
||||
".next",
|
||||
"tests/**/*",
|
||||
"**/*.test.{ts,tsx}",
|
||||
"vitest.config.ts",
|
||||
"vitest.setup.ts",
|
||||
export default defineConfig(() => {
|
||||
const apiBaseUrl =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost/api/v1";
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
restoreMocks: true,
|
||||
mockReset: true,
|
||||
unstubEnvs: true,
|
||||
unstubGlobals: true,
|
||||
coverage: {
|
||||
provider: "v8" as const,
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
"node_modules",
|
||||
".next",
|
||||
"tests/**/*",
|
||||
"**/*.test.{ts,tsx}",
|
||||
"**/*.browser.test.{ts,tsx}",
|
||||
"vitest.config.ts",
|
||||
"vitest.setup.ts",
|
||||
"vitest.browser.setup.ts",
|
||||
"__tests__/**/*",
|
||||
],
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "unit",
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["**/*.test.{ts,tsx}"],
|
||||
exclude: [
|
||||
"node_modules",
|
||||
".next",
|
||||
"tests/**/*",
|
||||
"**/*.browser.test.{ts,tsx}",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "browser",
|
||||
setupFiles: ["./vitest.browser.setup.ts"],
|
||||
include: ["**/*.browser.test.{ts,tsx}"],
|
||||
exclude: ["node_modules", ".next", "tests/**/*"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
// Vitest's browser default viewport is 414×896 (phone-sized),
|
||||
// which collapses the responsive layout: the legend stacks
|
||||
// vertically and ends up overlapping the graph, so Playwright
|
||||
// can't click nodes. Use a standard desktop viewport.
|
||||
viewport: { width: 1280, height: 800 },
|
||||
provider: playwright(),
|
||||
headless: true,
|
||||
instances: [{ browser: "chromium" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as TestProjectConfiguration[],
|
||||
},
|
||||
define: {
|
||||
"process.env.NEXT_PUBLIC_API_BASE_URL": JSON.stringify(apiBaseUrl),
|
||||
// `next/dist/server/web/spec-extension/user-agent.js` references
|
||||
// `__dirname` directly and is pulled in transitively via `next-auth`.
|
||||
// Vite serves it to the browser where that global doesn't exist, so we
|
||||
// replace it at bundle time. `optimizeDeps` alone doesn't help —
|
||||
// pre-bundling doesn't patch the identifier.
|
||||
__dirname: JSON.stringify("/"),
|
||||
__filename: JSON.stringify("/__browser_test__.js"),
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Pre-bundle every dep that the attack-paths page transitively imports.
|
||||
// Without this, Vite optimizes them on demand at the first request and
|
||||
// reloads the page, killing the test run. Keep this list aligned with
|
||||
// imports through the page's render tree.
|
||||
include: [
|
||||
// Test stack
|
||||
"vitest-browser-react",
|
||||
"msw/browser",
|
||||
|
||||
// Next runtime
|
||||
"next/navigation",
|
||||
"next/link",
|
||||
"next/image",
|
||||
"next/cache",
|
||||
"next/server",
|
||||
"next-auth",
|
||||
"next-auth/react",
|
||||
"next-auth/providers/credentials",
|
||||
"next-themes",
|
||||
|
||||
// App component lib
|
||||
"@heroui/react",
|
||||
"@heroui/accordion",
|
||||
"@heroui/breadcrumbs",
|
||||
"@heroui/card",
|
||||
"@heroui/chip",
|
||||
"@heroui/divider",
|
||||
"@heroui/input",
|
||||
"@heroui/switch",
|
||||
"@heroui/theme",
|
||||
"@heroui/tooltip",
|
||||
"@heroui/use-clipboard",
|
||||
"@iconify/react",
|
||||
|
||||
// Radix
|
||||
"@radix-ui/react-alert-dialog",
|
||||
"@radix-ui/react-avatar",
|
||||
"@radix-ui/react-checkbox",
|
||||
"@radix-ui/react-collapsible",
|
||||
"@radix-ui/react-dialog",
|
||||
"@radix-ui/react-dropdown-menu",
|
||||
"@radix-ui/react-icons",
|
||||
"@radix-ui/react-label",
|
||||
"@radix-ui/react-popover",
|
||||
"@radix-ui/react-radio-group",
|
||||
"@radix-ui/react-scroll-area",
|
||||
"@radix-ui/react-select",
|
||||
"@radix-ui/react-separator",
|
||||
"@radix-ui/react-tabs",
|
||||
"@radix-ui/react-toast",
|
||||
"@radix-ui/react-tooltip",
|
||||
"@radix-ui/react-slot",
|
||||
|
||||
// Graph
|
||||
"@xyflow/react",
|
||||
"@dagrejs/dagre",
|
||||
|
||||
// Forms / state
|
||||
"react-hook-form",
|
||||
"@hookform/resolvers/zod",
|
||||
"zod",
|
||||
"zustand",
|
||||
"zustand/middleware",
|
||||
|
||||
// Styling helpers
|
||||
"lucide-react",
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
"class-variance-authority",
|
||||
"tailwind-variants",
|
||||
|
||||
// App-level deps the page (or its children) pull in
|
||||
"@tanstack/react-table",
|
||||
"@react-aria/ssr",
|
||||
"@react-aria/visually-hidden",
|
||||
"modern-screenshot",
|
||||
"framer-motion",
|
||||
"vaul",
|
||||
"cmdk",
|
||||
"react-markdown",
|
||||
"jwt-decode",
|
||||
"date-fns",
|
||||
"js-yaml",
|
||||
"@codemirror/language",
|
||||
"@codemirror/state",
|
||||
"@lezer/highlight",
|
||||
"@uiw/react-codemirror",
|
||||
"@sentry/nextjs",
|
||||
"@extractus/feed-extractor",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user