commit ff8f1ca989bd2f6bcee5203ef575a8ec3377d1c6 Author: DaStra3003 <144953519+DaStra3003@users.noreply.github.com> Date: Wed Aug 19 08:04:59 2026 +0200 build 4.6 Demo diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..74823cc --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,81 @@ +# IoT PreTester 4.6 Demo — Architecture + +## Purpose and boundary + +IoT PreTester 4.6 Demo analyzes existing `.pcap` and `.pcapng` files and turns packet-level observations into prioritized security findings. It is intentionally limited to network-capture analysis. Live capture, streaming, and firmware binary analysis are outside the demo scope. + +## Runtime components + +| Component | Responsibility | +| --- | --- | +| Next.js frontend | Authentication flow, device administration, capture upload, findings display, report download, and optional deep-analysis controls | +| Django REST backend | API, validation, persistence, user/device administration, and integration with the analysis core | +| `analyzer` package | Django-independent capture extraction, detection, scoring, provenance, and report rendering | +| SQLite | Demo persistence for devices, files, findings, extracted artifacts, and analysis status | +| tshark/pyshark | Protocol dissection and normalized capture extraction | +| nmap/hashcat | Optional, explicitly initiated endpoint probing and offline hash recovery | + +## Analysis flow + +1. The frontend sends the capture and metadata to the authenticated Django API. +2. Django validates the upload, stores it in a dedicated volume, and creates the database records. +3. `extract_pyshark.py` parses the capture once into a normalized `CaptureContext`. +4. Ten independent detectors evaluate that shared context. +5. The engine deduplicates and prioritizes findings, then attaches capture, packet, stream, and artifact provenance. +6. Django persists the result and extracted URL/hash artifacts. +7. The frontend displays findings and can request Markdown, text, or JSON reports. +8. Optional URL/hash enrichment runs only for artifacts selected by the user. + +## Analysis package + +```text +django_project/analyzer/ +├── context.py Normalized capture data model +├── extract_pyshark.py PCAP/PCAPNG extraction +├── engine.py Detector orchestration and result assembly +├── findings.py Shared finding taxonomy +├── provenance.py Packet- and stream-level evidence references +├── scoring.py Deduplication and prioritization +├── artifacts.py URL and hash extraction +├── detectors/ Independent detection rules +├── enrich/ Optional active enrichment +├── reporting/ Markdown, text, and JSON output +└── tests/ Synthetic unit and regression tests +``` + +The package does not import Django. pyshark and external tools are loaded only at the integration points that need them, so detector tests can run on synthetic contexts without tshark. + +## Finding model + +Every detector produces the same core fields: ID, title, category, severity, confidence, affected device, source and destination, protocol, port, time range, evidence, risk description, recommendation, packet references, and stream references. + +Severity measures the potential impact if a finding is true. Confidence measures how strongly the capture supports it. Keeping these dimensions separate prevents low-confidence indicators from being presented as confirmed critical events. + +## Detector set + +1. Plaintext credentials +2. Password hashes and challenge-response material +3. Session tokens and secrets +4. Malware or command-and-control beaconing +5. Possible data exfiltration +6. Man-in-the-middle and local network manipulation +7. Insecure or unexpected protocols +8. Weak or obsolete TLS +9. DNS anomalies and tunneling indicators +10. Brute force, scanning, and suspicious file transfer + +## Design decisions + +- Parse each capture once and share normalized records across all detectors. +- Keep detector logic isolated from persistence, presentation, and external tools. +- Preserve evidence provenance so every finding can be traced back to the capture. +- Keep active probing opt-in because authorization can differ from authorization to inspect a capture. +- Use a small demo wordlist by default to keep distribution compact and behavior predictable. +- Store complete analysis results as JSON while keeping extracted artifacts and job status queryable. + +## Known demo limitations + +- SQLite and in-process background threads are suitable for a controlled demo, not horizontal production scaling. +- Capture quality and protocol encryption directly limit what can be detected. +- URL probes observe the endpoint at probe time, which can differ from capture time. +- The demo does not provide live packet capture, multi-tenant isolation, or production identity-provider integration. diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..bc8c366 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,41 @@ +# IoT PreTester 4.6 Demo backend +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DEBIAN_FRONTEND=noninteractive + +WORKDIR /app + +# tshark performs capture dissection; nmap and hashcat support opt-in enrichment. +RUN apt-get update && apt-get install -y --no-install-recommends \ + hashcat \ + nmap \ + pocl-opencl-icd \ + sqlite3 \ + tshark \ + wget \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 appgroup \ + && useradd -m -u 1000 -g 1000 appuser \ + && mkdir -p /app/data /app/files/uploads /app/logs /app/demo_files /app/django_project/staticfiles \ + && chown -R appuser:appgroup /app + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appgroup django_project/ /app/django_project/ +COPY --chown=appuser:appgroup demo_files/demo_wordlist.txt /app/demo_files/demo_wordlist.txt +COPY --chown=appuser:appgroup docker/entrypoint-backend.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +USER appuser +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/api/health || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..f680b1b --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,37 @@ +# IoT PreTester 4.6 Demo frontend +FROM node:18-alpine AS dependencies +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci + +FROM node:18-alpine AS builder +WORKDIR /app +COPY --from=dependencies /app/node_modules ./node_modules +COPY frontend/ ./ +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} +RUN npm run build + +FROM node:18-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +RUN chown -R node:node /app + +COPY --chown=node:node docker/entrypoint-frontend.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/login || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/IoT-PreTester-4.6-Demo.zip b/IoT-PreTester-4.6-Demo.zip new file mode 100644 index 0000000..e92896c Binary files /dev/null and b/IoT-PreTester-4.6-Demo.zip differ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1a2238a --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +# Convenience commands for IoT PreTester 4.6 Demo +.PHONY: help setup build rebuild up down restart status logs logs-backend logs-frontend test check shell-backend shell-frontend migrate + +help: + @echo "IoT PreTester 4.6 Demo" + @echo " make setup Create .env when needed and build the images" + @echo " make up Start the demo" + @echo " make down Stop and remove the containers" + @echo " make status Show container health" + @echo " make logs Follow all logs" + @echo " make test Run the backend test suite" + @echo " make check Check the public health endpoints" + @echo " make rebuild Rebuild and recreate both containers" + +setup: + @test -f .env || cp .env.example .env + docker compose build + @echo "Edit .env if needed, then run 'make up'." + +build: + docker compose build + +rebuild: + docker compose up -d --build --force-recreate + +up: + docker compose up -d + @echo "Frontend: http://localhost:3000" + @echo "Backend: http://localhost:8000/api/health" + +down: + docker compose down + +restart: + docker compose restart + +status: + docker compose ps + +logs: + docker compose logs -f + +logs-backend: + docker compose logs -f backend + +logs-frontend: + docker compose logs -f frontend + +test: + docker compose exec backend python /app/django_project/manage.py test analyzer + +check: + @curl --fail --silent http://localhost:8000/api/health + @curl --fail --silent http://localhost:3000/login >/dev/null + @echo "IoT PreTester 4.6 Demo is reachable." + +shell-backend: + docker compose exec --workdir /app/django_project backend sh + +shell-frontend: + docker compose exec frontend sh + +migrate: + docker compose exec backend python /app/django_project/manage.py migrate diff --git a/README.md b/README.md new file mode 100644 index 0000000..bde6451 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# IoT PreTester 4.6 Demo + +IoT PreTester 4.6 Demo is a partner-ready demonstration platform for security analysis of IoT network captures. A Django REST backend extracts and evaluates PCAP/PCAPNG traffic, while a Next.js frontend manages devices, starts analyses, and presents prioritized findings with packet-level evidence. + +## Demo scope + +- Upload and analyze `.pcap` and `.pcapng` captures. +- Detect credential exposure, secrets, suspicious traffic patterns, weak protocols, TLS issues, DNS anomalies, scanning, and possible exfiltration. +- Review affected endpoints, evidence, packet references, severity, confidence, and recommendations. +- Export reports as Markdown, plain text, or JSON. +- Optionally run user-selected URL probes and offline hash checks. +- Manage demo devices, manufacturers, device types, and users. + +Firmware binary analysis and historical prototype pages are intentionally not part of this demo. + +## Requirements + +- Docker Desktop or Docker Engine with Docker Compose +- At least 4 GB of free memory for the two application containers +- A modern web browser + +No local Python, Node.js, tshark, nmap, or hashcat installation is required for the Docker workflow. + +## Quick start + +1. Create the local configuration: + + ```bash + cp .env.example .env + ``` + +2. Set secure values for `SECRET_KEY` and `DJANGO_SUPERUSER_PASSWORD` in `.env`. + +3. Build and start the demo: + + ```bash + docker compose up -d --build + docker compose ps + ``` + +4. Open [http://localhost:3000](http://localhost:3000) and sign in with the admin credentials from `.env`. + +5. Open the sample device, upload [`demo_files/demo_capture.pcapng`](./demo_files/demo_capture.pcapng), enter the device IP and firmware metadata, then start the scan. + +The initial image build can take several minutes because the backend includes network-analysis tools. The application is ready when both containers are healthy. + +## Common commands + +| Task | Command | +| --- | --- | +| Start | `docker compose up -d` | +| Stop | `docker compose stop` | +| Status | `docker compose ps` | +| Follow logs | `docker compose logs -f` | +| Run backend tests | `make test` | +| Rebuild | `docker compose up -d --build --force-recreate` | + +Equivalent shortcuts are available through `make help`. + +## Repository structure + +```text +. +├── frontend/ Next.js user interface +├── django_project/ +│ ├── IoTAnalyzer/ Django API and persistence adapters +│ ├── IoTPreTester/ Django configuration +│ └── analyzer/ Framework-independent analysis engine +├── demo_files/ Partner-safe sample capture and wordlist +├── docker/ Container entrypoints +├── ARCHITECTURE.md Architecture and security-detection design +├── docker-compose.yml Demo runtime +└── Makefile Convenience commands +``` + +## Headless analysis + +The analysis core can also run without Django: + +```bash +cd django_project +python -m analyzer.cli ../demo_files/demo_capture.pcapng -f md -o report.md +``` + +Exit code `0` means no high/critical findings, `2` means at least one high/critical finding, and `1` indicates an execution error. + +## Demo safety notes + +- Only upload captures that you are authorized to inspect. Captures can contain credentials, tokens, personal data, and internal addresses. +- Active URL probing is started manually for selected endpoints. Do not probe third-party systems without permission. +- Hash recovery uses the deliberately small `demo_files/demo_wordlist.txt`. Supply an authorized external wordlist only when required. +- This configuration is intended for controlled demonstrations, not public internet exposure. +- Runtime data, uploaded captures, logs, and `.env` are excluded from version control. + +## Documentation + +- [`ARCHITECTURE.md`](./ARCHITECTURE.md) describes the data flow, detector model, and module boundaries. +- [`django_project/analyzer/README.md`](./django_project/analyzer/README.md) documents the standalone analysis package. + +## Distribution + +This repository is supplied as **IoT PreTester 4.6 Demo** for an agreed partner demonstration. No public license is granted by the repository; redistribution or commercial use requires the owner's written permission. diff --git a/demo_files/demo_capture.pcapng b/demo_files/demo_capture.pcapng new file mode 100644 index 0000000..6c8df43 Binary files /dev/null and b/demo_files/demo_capture.pcapng differ diff --git a/demo_files/demo_wordlist.txt b/demo_files/demo_wordlist.txt new file mode 100644 index 0000000..174b356 --- /dev/null +++ b/demo_files/demo_wordlist.txt @@ -0,0 +1,8 @@ +# Deliberately small wordlist for the IoT PreTester 4.6 Demo. +admin +admin123 +changeme +password +password123 +root +test diff --git a/django_project/IoTAnalyzer/__init__.py b/django_project/IoTAnalyzer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/IoTAnalyzer/admin.py b/django_project/IoTAnalyzer/admin.py new file mode 100644 index 0000000..c00d07f --- /dev/null +++ b/django_project/IoTAnalyzer/admin.py @@ -0,0 +1,51 @@ +"""Django admin registrations for demo operations.""" + +from django.contrib import admin + +from .models import ( + AnalyzedDataset, + CipherSuite, + DeepAnalysisJob, + Device, + DeviceType, + File, + FindingReport, + Firmware, + FirmwareDevices, + HashAnalyzed, + HashRaw, + HashType, + Manufacturer, + Port, + Product, + RawDataset, + TlsVersion, + Tool, + UrlAnalyzed, + UrlRaw, +) + +admin.site.register( + [ + Manufacturer, + DeviceType, + Product, + Device, + Firmware, + FirmwareDevices, + File, + RawDataset, + HashRaw, + UrlRaw, + Tool, + Port, + TlsVersion, + CipherSuite, + HashType, + AnalyzedDataset, + UrlAnalyzed, + HashAnalyzed, + FindingReport, + DeepAnalysisJob, + ] +) diff --git a/django_project/IoTAnalyzer/analyze/deep_analysis.py b/django_project/IoTAnalyzer/analyze/deep_analysis.py new file mode 100644 index 0000000..8771d2e --- /dev/null +++ b/django_project/IoTAnalyzer/analyze/deep_analysis.py @@ -0,0 +1,168 @@ +"""Optional hash cracking and endpoint probing with progress persistence.""" + +from __future__ import annotations + +import logging +import threading + +from django.db import connection + +logger = logging.getLogger(__name__) + + +def start_deep_analysis(file_id: int, url_ids=None, hash_ids=None, + synchronous: bool = False) -> None: + """Start enrichment for all artifacts or the explicitly selected raw IDs.""" + if synchronous: + _run(file_id, url_ids, hash_ids, close_conn=False) + return + thread = threading.Thread(target=_run, args=(file_id, url_ids, hash_ids), daemon=True, + name=f"deep-analysis-{file_id}") + thread.start() + + +def _analyzed_dataset_for(raw_obj): + """Return the single analyzed dataset associated with a capture.""" + from IoTAnalyzer.models import AnalyzedDataset + file_obj = raw_obj.raw_dataset.file + ds, _ = AnalyzedDataset.objects.get_or_create(file=file_obj) + return ds + + +def crack_raw_hash(raw_hash) -> None: + """Crack one raw hash and persist the result idempotently.""" + from django.db import transaction + from IoTAnalyzer.models import Tool, HashType, HashAnalyzed + from analyzer.artifacts import HashArtifact + from analyzer.enrich import crack_hash + + try: + res = crack_hash(HashArtifact(value=raw_hash.hash_value)) + cracked = bool(res.cracked and res.plaintext) + logger.info("Hash id=%s mode=%s state=%s cracked=%s error=%s", + raw_hash.id, res.hashcat_mode, res.state, cracked, res.error) + + ds = _analyzed_dataset_for(raw_hash) + tool, _ = Tool.objects.get_or_create(tool_name="Hashcat", defaults={"version": "6.x.x"}) + htype, _ = HashType.objects.get_or_create(hash_type=(res.hash_format or "unknown")) + crack_time = f"{res.duration_seconds}s" if res.duration_seconds is not None else None + + with transaction.atomic(): + ha, created = HashAnalyzed.objects.get_or_create( + analyzed_dataset=ds, hash_value=res.value, + defaults={"tool": tool, "cracked": cracked, + "cracked_value": res.plaintext if cracked else None, + "crack_time": crack_time}, + ) + if not created: + ha.tool = tool + ha.cracked = cracked + ha.cracked_value = res.plaintext if cracked else None + ha.crack_time = crack_time + ha.save() + ha.hash_type.add(htype) + logger.info("Stored hash result id=%s (created=%s, cracked=%s)", ha.id, created, cracked) + except Exception as exc: + logger.exception("Hash enrichment failed (id=%s): %s", raw_hash.id, exc) + + +def probe_raw_url(raw_url) -> None: + """Probe one raw endpoint and persist DNS, port, and TLS results.""" + from django.db import transaction + from IoTAnalyzer.models import Tool, UrlAnalyzed, Port, TlsVersion, CipherSuite + from analyzer.enrich import probe_url + + try: + probe = probe_url(raw_url.url, active=True) + logger.info("URL id=%s ports=%s tls=%s error=%s", + raw_url.id, probe.open_ports, probe.tls_versions, probe.error) + + ds = _analyzed_dataset_for(raw_url) + tool, _ = Tool.objects.get_or_create(tool_name="DNS Analyzer", defaults={"version": "1.0"}) + + with transaction.atomic(): + ua, created = UrlAnalyzed.objects.get_or_create( + analyzed_dataset=ds, url=raw_url.url, defaults={"tool": tool}) + for p in probe.open_ports: + port, _ = Port.objects.get_or_create(port=p) + ua.ports.add(port) + for v in (probe.tls_versions or ["N/A"]): + tls, _ = TlsVersion.objects.get_or_create(tls_version=v) + ua.tls_versions.add(tls) + for c in (probe.cipher_suites or ["N/A"]): + cs, _ = CipherSuite.objects.get_or_create(cipher_suite=c) + ua.cipher_suites.add(cs) + logger.info("Stored endpoint result id=%s (created=%s)", ua.id, created) + except Exception as exc: + logger.exception("Endpoint enrichment failed (id=%s): %s", raw_url.id, exc) + + +def _run(file_id: int, url_ids=None, hash_ids=None, close_conn: bool = True) -> None: + from IoTAnalyzer.models import UrlRaw, HashRaw, HashAnalyzed, DeepAnalysisJob + + job = None + try: + url_qs = UrlRaw.objects.filter(raw_dataset__file_id=file_id) + hash_qs = HashRaw.objects.filter(raw_dataset__file_id=file_id) + # None selects all records; an empty list intentionally selects none. + if url_ids is not None: + url_qs = url_qs.filter(id__in=url_ids) + if hash_ids is not None: + hash_qs = hash_qs.filter(id__in=hash_ids) + urls = list(url_qs) + hashes = list(hash_qs) + + DeepAnalysisJob.objects.filter(file_id=file_id).delete() + job = DeepAnalysisJob.objects.create( + file_id=file_id, state='running', + url_total=len(urls), hash_total=len(hashes), + message='Enrichment started', + ) + logger.info("Enrichment started: file=%s urls=%s hashes=%s", + file_id, len(urls), len(hashes)) + + # Crack hashes first because they usually complete quickly. + for raw_hash in hashes: + try: + crack_raw_hash(raw_hash) + except Exception as exc: + logger.exception("Hash cracking failed (id=%s): %s", raw_hash.id, exc) + job.hash_done += 1 + job.message = f"Hash-Cracking {job.hash_done}/{job.hash_total}" + job.save(update_fields=['hash_done', 'message', 'updated']) + + # Endpoint probing may perform DNS, port, and TLS checks. + for raw_url in urls: + try: + probe_raw_url(raw_url) + except Exception as exc: + logger.exception("Endpoint probing failed (id=%s): %s", raw_url.id, exc) + job.url_done += 1 + job.message = f"Endpoint probing {job.url_done}/{job.url_total}" + job.save(update_fields=['url_done', 'message', 'updated']) + + try: + job.cracked = HashAnalyzed.objects.filter( + analyzed_dataset__file_id=file_id, cracked=True + ).count() + except Exception: + pass + + job.state = 'done' + job.message = 'Enrichment completed' + job.save() + logger.info("Enrichment completed: file=%s cracked=%s", file_id, job.cracked) + + except Exception as exc: + logger.exception("Enrichment failed for file=%s: %s", file_id, exc) + if job is not None: + job.state = 'error' + job.message = str(exc)[:500] + try: + job.save() + except Exception: + pass + finally: + # Close only the worker thread's connection, never the request connection. + if close_conn: + connection.close() diff --git a/django_project/IoTAnalyzer/analyze/security_analysis.py b/django_project/IoTAnalyzer/analyze/security_analysis.py new file mode 100644 index 0000000..db6dd4a --- /dev/null +++ b/django_project/IoTAnalyzer/analyze/security_analysis.py @@ -0,0 +1,146 @@ +"""Persist results and artifacts produced by the shared analysis context.""" + +from __future__ import annotations + +import json +import logging +import os + +from django.conf import settings +from analyzer import AnalysisEngine, extract_urls, extract_hashes +from analyzer.extract_pyshark import build_context_from_pcap +from analyzer.findings import Severity +from IoTAnalyzer.models import FindingReport, UrlRaw, HashRaw + +logger = logging.getLogger(__name__) + +_CANDIDATE_DIRS = [ + str(settings.CAPTURE_UPLOAD_DIR), +] + + +def resolve_capture_path(filename: str) -> str | None: + """Resolve a stored capture name against supported upload locations.""" + if not filename: + return None + if os.path.isabs(filename) and os.path.exists(filename): + return filename + for base in _CANDIDATE_DIRS: + candidate = os.path.join(base, filename) + if os.path.exists(candidate): + return candidate + return None + + +def _max_severity(result) -> str: + worst = Severity.INFO + for f in result.findings: + if f.severity.rank > worst.rank: + worst = f.severity + return worst.value + + +def collect_cloud_results(file_id: int) -> dict: + """Return deduplicated capture artifacts and any enrichment results.""" + from IoTAnalyzer.models import UrlRaw, HashRaw, UrlAnalyzed, HashAnalyzed + + # Retain raw IDs so the frontend can request a selected enrichment subset. + raw_urls = list(UrlRaw.objects.filter(raw_dataset__file_id=file_id) + .values_list('id', 'url')) + raw_hashes = list(HashRaw.objects.filter(raw_dataset__file_id=file_id) + .values_list('id', 'hash_value')) + + url_map = {} + for ua in (UrlAnalyzed.objects.filter(analyzed_dataset__file_id=file_id) + .prefetch_related('ports', 'tls_versions', 'cipher_suites')): + url_map[ua.url] = { + "ports": sorted({p.port for p in ua.ports.all()}), + "tls_versions": [t.tls_version for t in ua.tls_versions.all()], + "cipher_suites": [c.cipher_suite for c in ua.cipher_suites.all()], + } + + hash_map = {} + for ha in (HashAnalyzed.objects.filter(analyzed_dataset__file_id=file_id) + .prefetch_related('hash_type')): + entry = { + "cracked": ha.cracked, + "cracked_value": ha.cracked_value, + "crack_time": ha.crack_time, + "hash_type": [h.hash_type for h in ha.hash_type.all()], + } + existing = hash_map.get(ha.hash_value) + if existing is None or (ha.cracked and not existing.get("cracked")): + hash_map[ha.hash_value] = entry + + urls, seen = [], set() + for raw_id, u in raw_urls: + if not u or u in seen: + continue + seen.add(u) + a = url_map.get(u) + urls.append({"id": raw_id, "url": u, "analyzed": a is not None, **(a or {})}) + + hashes, seen_h = [], set() + for raw_id, h in raw_hashes: + if not h or h in seen_h: + continue + seen_h.add(h) + a = hash_map.get(h) + hashes.append({"id": raw_id, "hash_value": h, "analyzed": a is not None, + **(a or {"cracked": False, "cracked_value": None, + "crack_time": None, "hash_type": []})}) + + return {"urls": urls, "hashes": hashes} + + +def _persist_artifacts(ctx, raw_dataset_id: int) -> None: + """Persist URL and hash artifacts extracted during the analysis pass.""" + if raw_dataset_id is None: + return + try: + for u in extract_urls(ctx): + UrlRaw.objects.get_or_create(raw_dataset_id=raw_dataset_id, url=u.value[:200]) + except Exception as exc: + logger.exception("URL persistence failed: %s", exc) + try: + for h in extract_hashes(ctx): + HashRaw.objects.get_or_create(raw_dataset_id=raw_dataset_id, hash_value=h.value) + except Exception as exc: + logger.exception("Hash persistence failed: %s", exc) + + +def analyze_capture(filename: str, file_id: int, raw_dataset_id: int | None = None): + """Analyze a capture once and persist findings plus extracted artifacts.""" + path = resolve_capture_path(filename) + if not path: + logger.warning("Capture file not found: %s", filename) + return None + + try: + ctx = build_context_from_pcap(path) + result = AnalysisEngine().analyze_context(ctx) + except Exception as exc: + logger.exception("Security analysis failed for %s: %s", filename, exc) + return None + + try: + # Reanalysis replaces the previous report atomically at the file level. + FindingReport.objects.filter(file_id=file_id).delete() + FindingReport.objects.create( + file_id=file_id, + result_json=json.dumps(result.to_dict()), + finding_count=len(result.findings), + max_severity=_max_severity(result), + ) + logger.info("Stored finding report: file_id=%s, findings=%s", + file_id, len(result.findings)) + except Exception as exc: + logger.exception("Finding report persistence failed: %s", exc) + + _persist_artifacts(ctx, raw_dataset_id) + + return result + + +def run_security_analysis(filename: str, file_id: int): + return analyze_capture(filename, file_id, raw_dataset_id=None) diff --git a/django_project/IoTAnalyzer/apps.py b/django_project/IoTAnalyzer/apps.py new file mode 100644 index 0000000..e61bb58 --- /dev/null +++ b/django_project/IoTAnalyzer/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IotanalyzerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'IoTAnalyzer' diff --git a/django_project/IoTAnalyzer/authentication.py b/django_project/IoTAnalyzer/authentication.py new file mode 100644 index 0000000..d287387 --- /dev/null +++ b/django_project/IoTAnalyzer/authentication.py @@ -0,0 +1,13 @@ +"""Session authentication adapted to the localhost-only demo API. + +State-changing requests remain protected by authenticated session cookies and a +strict CORS allow-list. The separate CSRF check is disabled because the frontend +and API use different localhost ports. +""" + +from rest_framework.authentication import SessionAuthentication + + +class DemoSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return None diff --git a/django_project/IoTAnalyzer/db_create.py b/django_project/IoTAnalyzer/db_create.py new file mode 100644 index 0000000..6c4b8fe --- /dev/null +++ b/django_project/IoTAnalyzer/db_create.py @@ -0,0 +1,122 @@ +"""Focused creation services used by the demo API.""" + +import ipaddress +import logging +import os + +from django.core.exceptions import ValidationError +from django.db import transaction + +from .models import ( + Device, + DeviceType, + File, + Firmware, + FirmwareDevices, + Manufacturer, + Product, + RawDataset, +) + +logger = logging.getLogger(__name__) +CAPTURE_EXTENSIONS = {".pcap", ".pcapng"} + + +def _required_text(value, label: str, max_length: int) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValidationError(f"{label} is required.") + value = value.strip() + if len(value) > max_length: + raise ValidationError(f"{label} must not exceed {max_length} characters.") + return value + + +@transaction.atomic +def create_manufacturer(name: str) -> Manufacturer: + name = _required_text(name, "Manufacturer name", 50) + manufacturer, created = Manufacturer.objects.get_or_create(name=name) + if not created: + raise ValidationError("This manufacturer already exists.") + return manufacturer + + +@transaction.atomic +def create_device_type(device_type: str) -> DeviceType: + device_type = _required_text(device_type, "Device type", 50) + instance, created = DeviceType.objects.get_or_create(device_type=device_type) + if not created: + raise ValidationError("This device type already exists.") + return instance + + +@transaction.atomic +def create_new_device( + manufacturer_id: int, + device_type_id: int, + device_name: str, + serial: str, + comment: str = "", +) -> Device: + manufacturer = Manufacturer.objects.get(id=manufacturer_id) + device_type = DeviceType.objects.get(id=device_type_id) + device_name = _required_text(device_name, "Device name", 100) + serial = _required_text(serial, "Serial number", 100) + comment = (comment or "").strip()[:255] + + product, _ = Product.objects.get_or_create( + manufacturer=manufacturer, + device_type=device_type, + product_name=device_name, + ) + device, created = Device.objects.get_or_create( + product=product, + serial=serial, + defaults={"comment": comment}, + ) + if not created: + raise ValidationError("A device with this product and serial number already exists.") + return device + + +def create_capture( + device_id: int, + firmware_version: str, + filename: str, + ip_address: str, +) -> File: + """Create capture metadata, analyze the stored file, and update its status.""" + firmware_version = _required_text(firmware_version, "Firmware version", 100) + filename = os.path.basename(_required_text(filename, "Capture filename", 255)) + if os.path.splitext(filename)[1].lower() not in CAPTURE_EXTENSIONS: + raise ValidationError("Only .pcap and .pcapng captures are supported.") + + try: + normalized_ip = str(ipaddress.ip_address(ip_address)) + except ValueError as exc: + raise ValidationError("A valid device IP address is required.") from exc + + with transaction.atomic(): + device = Device.objects.select_related("product").get(id=device_id) + firmware, _ = Firmware.objects.get_or_create( + product=device.product, + version=firmware_version, + ) + FirmwareDevices.objects.get_or_create(device=device, firmware=firmware) + file_entry, created = File.objects.get_or_create( + device=device, + firmware=firmware, + filename=filename, + ip_address=normalized_ip, + defaults={"status": "analyzing"}, + ) + if not created: + raise ValidationError("This capture is already registered for the device.") + raw_dataset = RawDataset.objects.create(file=file_entry) + + from .analyze.security_analysis import analyze_capture + + result = analyze_capture(filename, file_entry.id, raw_dataset.id) + file_entry.status = "analyzed" if result is not None else "analysis_failed" + file_entry.save(update_fields=["status"]) + logger.info("Capture %s completed with status %s", file_entry.id, file_entry.status) + return file_entry diff --git a/django_project/IoTAnalyzer/db_read.py b/django_project/IoTAnalyzer/db_read.py new file mode 100644 index 0000000..a9c35a4 --- /dev/null +++ b/django_project/IoTAnalyzer/db_read.py @@ -0,0 +1,51 @@ +"""Read projections used by the demo API.""" + +from .models import Device, DeviceType, File, Manufacturer + + +def get_all_manufacturers() -> list[dict]: + return [item.to_json() for item in Manufacturer.objects.all()] + + +def get_all_device_types() -> list[dict]: + return [item.to_json() for item in DeviceType.objects.all()] + + +def get_all_devices() -> list[dict]: + devices = Device.objects.select_related( + "product", "product__manufacturer", "product__device_type" + ) + return [ + { + "id": device.id, + "manufacturer_name": device.product.manufacturer.name, + "device_type": device.product.device_type.device_type, + "device_name": device.product.product_name, + "serial": device.serial, + "comment": device.comment, + "created": device.created.isoformat(), + } + for device in devices + ] + + +def get_device_by_id(device_id: int) -> dict: + device = Device.objects.select_related( + "product", "product__manufacturer", "product__device_type" + ).get(id=device_id) + return device.to_json() + + +def get_files_by_device(device_id: int) -> list[dict]: + files = File.objects.filter(device_id=device_id).select_related("firmware") + return [ + { + "id": file.id, + "filename": file.filename, + "ip": file.ip_address, + "state": file.status, + "upload": file.upload_time.isoformat(), + "firmware_version": file.firmware.version, + } + for file in files + ] diff --git a/django_project/IoTAnalyzer/migrations/0001_initial.py b/django_project/IoTAnalyzer/migrations/0001_initial.py new file mode 100644 index 0000000..d1683fc --- /dev/null +++ b/django_project/IoTAnalyzer/migrations/0001_initial.py @@ -0,0 +1,333 @@ +# Generated by Django 4.2.20 on 2026-07-17 12:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AnalyzedDataset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_analyzed_dataset', + }, + ), + migrations.CreateModel( + name='CipherSuite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cipher_suite', models.CharField(max_length=100, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_cipher_suites', + }, + ), + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('serial', models.CharField(max_length=100)), + ('comment', models.CharField(blank=True, default='', max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_device', + }, + ), + migrations.CreateModel( + name='DeviceType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('device_type', models.CharField(max_length=50, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_device_type', + 'ordering': ['device_type'], + }, + ), + migrations.CreateModel( + name='File', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('filename', models.CharField(max_length=255)), + ('ip_address', models.GenericIPAddressField()), + ('status', models.CharField(default='pending', max_length=50)), + ('upload_time', models.DateTimeField(auto_now_add=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.device')), + ], + options={ + 'db_table': 'iot_files', + 'ordering': ['-upload_time'], + }, + ), + migrations.CreateModel( + name='Firmware', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.CharField(max_length=100)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_firmware', + }, + ), + migrations.CreateModel( + name='HashType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hash_type', models.CharField(max_length=100, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_hash_types', + }, + ), + migrations.CreateModel( + name='Manufacturer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_manufacturer', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Port', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('port', models.PositiveIntegerField(unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_ports', + }, + ), + migrations.CreateModel( + name='RawDataset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('file', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file')), + ], + options={ + 'db_table': 'iot_raw_dataset', + }, + ), + migrations.CreateModel( + name='TlsVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tls_version', models.CharField(max_length=50, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_tls_versions', + }, + ), + migrations.CreateModel( + name='Tool', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tool_name', models.CharField(max_length=100, unique=True)), + ('version', models.CharField(max_length=50)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'iot_tool', + }, + ), + migrations.CreateModel( + name='UrlRaw', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.CharField(max_length=500)), + ('created', models.DateTimeField(auto_now_add=True)), + ('raw_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.rawdataset')), + ], + options={ + 'db_table': 'iot_raw_url', + }, + ), + migrations.CreateModel( + name='UrlAnalyzed', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.CharField(max_length=500)), + ('created', models.DateTimeField(auto_now_add=True)), + ('analyzed_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.analyzeddataset')), + ('cipher_suites', models.ManyToManyField(to='IoTAnalyzer.ciphersuite')), + ('ports', models.ManyToManyField(to='IoTAnalyzer.port')), + ('tls_versions', models.ManyToManyField(to='IoTAnalyzer.tlsversion')), + ('tool', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.tool')), + ], + options={ + 'db_table': 'iot_analyzed_url', + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('product_name', models.CharField(max_length=100)), + ('created', models.DateTimeField(auto_now_add=True)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.devicetype')), + ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.manufacturer')), + ], + options={ + 'db_table': 'iot_product', + 'ordering': ['product_name'], + }, + ), + migrations.CreateModel( + name='HashRaw', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hash_value', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('raw_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.rawdataset')), + ], + options={ + 'db_table': 'iot_raw_hash', + }, + ), + migrations.CreateModel( + name='HashAnalyzed', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hash_value', models.TextField()), + ('cracked', models.BooleanField(default=False)), + ('cracked_value', models.CharField(blank=True, max_length=255, null=True)), + ('crack_time', models.CharField(blank=True, max_length=50, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('analyzed_dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.analyzeddataset')), + ('hash_type', models.ManyToManyField(to='IoTAnalyzer.hashtype')), + ('tool', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.tool')), + ], + options={ + 'db_table': 'iot_analyzed_hash', + }, + ), + migrations.CreateModel( + name='FirmwareDevices', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.device')), + ('firmware', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.firmware')), + ], + options={ + 'db_table': 'iot_firmware_devices', + }, + ), + migrations.AddField( + model_name='firmware', + name='devices', + field=models.ManyToManyField(through='IoTAnalyzer.FirmwareDevices', to='IoTAnalyzer.device'), + ), + migrations.AddField( + model_name='firmware', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.product'), + ), + migrations.CreateModel( + name='FindingReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('result_json', models.TextField()), + ('finding_count', models.IntegerField(default=0)), + ('max_severity', models.CharField(default='info', max_length=20)), + ('created', models.DateTimeField(auto_now_add=True)), + ('file', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file')), + ], + options={ + 'db_table': 'iot_finding_report', + 'ordering': ['-created'], + }, + ), + migrations.AddField( + model_name='file', + name='firmware', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.firmware'), + ), + migrations.AddField( + model_name='device', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='IoTAnalyzer.product'), + ), + migrations.CreateModel( + name='DeepAnalysisJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('state', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('done', 'Done'), ('error', 'Error')], default='pending', max_length=20)), + ('url_total', models.IntegerField(default=0)), + ('url_done', models.IntegerField(default=0)), + ('hash_total', models.IntegerField(default=0)), + ('hash_done', models.IntegerField(default=0)), + ('cracked', models.IntegerField(default=0)), + ('message', models.TextField(blank=True, default='')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('file', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file')), + ], + options={ + 'db_table': 'iot_deep_analysis_job', + 'ordering': ['-created'], + }, + ), + migrations.AddField( + model_name='analyzeddataset', + name='file', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='IoTAnalyzer.file'), + ), + migrations.AddConstraint( + model_name='urlraw', + constraint=models.UniqueConstraint(fields=('raw_dataset', 'url'), name='unique_raw_url'), + ), + migrations.AddConstraint( + model_name='urlanalyzed', + constraint=models.UniqueConstraint(fields=('analyzed_dataset', 'url'), name='unique_analyzed_url'), + ), + migrations.AddConstraint( + model_name='product', + constraint=models.UniqueConstraint(fields=('manufacturer', 'device_type', 'product_name'), name='unique_demo_product'), + ), + migrations.AddConstraint( + model_name='hashraw', + constraint=models.UniqueConstraint(fields=('raw_dataset', 'hash_value'), name='unique_raw_hash'), + ), + migrations.AddConstraint( + model_name='hashanalyzed', + constraint=models.UniqueConstraint(fields=('analyzed_dataset', 'hash_value'), name='unique_analyzed_hash'), + ), + migrations.AddConstraint( + model_name='firmwaredevices', + constraint=models.UniqueConstraint(fields=('firmware', 'device'), name='unique_device_firmware'), + ), + migrations.AddConstraint( + model_name='firmware', + constraint=models.UniqueConstraint(fields=('product', 'version'), name='unique_product_firmware'), + ), + migrations.AddConstraint( + model_name='file', + constraint=models.UniqueConstraint(fields=('device', 'firmware', 'filename', 'ip_address'), name='unique_device_capture'), + ), + migrations.AddConstraint( + model_name='device', + constraint=models.UniqueConstraint(fields=('product', 'serial'), name='unique_demo_device'), + ), + ] diff --git a/django_project/IoTAnalyzer/migrations/__init__.py b/django_project/IoTAnalyzer/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/IoTAnalyzer/models.py b/django_project/IoTAnalyzer/models.py new file mode 100644 index 0000000..f6adf6b --- /dev/null +++ b/django_project/IoTAnalyzer/models.py @@ -0,0 +1,337 @@ +"""Persistence models for IoT PreTester 4.6 Demo.""" + +import json + +from django.db import models + + +class Manufacturer(models.Model): + name = models.CharField(max_length=50, unique=True) + created = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return {"id": self.id, "name": self.name, "created": self.created.isoformat()} + + class Meta: + db_table = "iot_manufacturer" + ordering = ["name"] + + +class DeviceType(models.Model): + device_type = models.CharField(max_length=50, unique=True) + created = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return { + "id": self.id, + "device_type": self.device_type, + "created": self.created.isoformat(), + } + + class Meta: + db_table = "iot_device_type" + ordering = ["device_type"] + + +class Product(models.Model): + manufacturer = models.ForeignKey(Manufacturer, on_delete=models.PROTECT) + device_type = models.ForeignKey(DeviceType, on_delete=models.PROTECT) + product_name = models.CharField(max_length=100) + created = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return { + "id": self.id, + "manufacturer": self.manufacturer.to_json(), + "product_name": self.product_name, + "device_type": self.device_type.to_json(), + "created": self.created.isoformat(), + } + + class Meta: + db_table = "iot_product" + ordering = ["product_name"] + constraints = [ + models.UniqueConstraint( + fields=["manufacturer", "device_type", "product_name"], + name="unique_demo_product", + ) + ] + + +class Device(models.Model): + product = models.ForeignKey(Product, on_delete=models.PROTECT) + serial = models.CharField(max_length=100) + comment = models.CharField(max_length=255, blank=True, default="") + created = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return { + "id": self.id, + "product": self.product.to_json(), + "serial": self.serial, + "comment": self.comment, + "created": self.created.isoformat(), + } + + class Meta: + db_table = "iot_device" + constraints = [ + models.UniqueConstraint( + fields=["product", "serial"], name="unique_demo_device" + ) + ] + + +class Firmware(models.Model): + """Firmware metadata associated with a capture; binaries are not analyzed.""" + + version = models.CharField(max_length=100) + product = models.ForeignKey(Product, on_delete=models.PROTECT) + devices = models.ManyToManyField(Device, through="FirmwareDevices") + created = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return { + "id": self.id, + "version": self.version, + "product": self.product_id, + "created": self.created.isoformat(), + } + + class Meta: + db_table = "iot_firmware" + constraints = [ + models.UniqueConstraint( + fields=["product", "version"], name="unique_product_firmware" + ) + ] + + +class FirmwareDevices(models.Model): + firmware = models.ForeignKey(Firmware, on_delete=models.CASCADE) + device = models.ForeignKey(Device, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_firmware_devices" + constraints = [ + models.UniqueConstraint( + fields=["firmware", "device"], name="unique_device_firmware" + ) + ] + + +class File(models.Model): + firmware = models.ForeignKey(Firmware, on_delete=models.PROTECT) + device = models.ForeignKey(Device, on_delete=models.CASCADE) + filename = models.CharField(max_length=255) + ip_address = models.GenericIPAddressField() + status = models.CharField(max_length=50, default="pending") + upload_time = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return { + "id": self.id, + "filename": self.filename, + "ip_address": self.ip_address, + "firmware_version": self.firmware.version, + "status": self.status, + "upload_time": self.upload_time.isoformat(), + } + + class Meta: + db_table = "iot_files" + ordering = ["-upload_time"] + constraints = [ + models.UniqueConstraint( + fields=["device", "firmware", "filename", "ip_address"], + name="unique_device_capture", + ) + ] + + +class RawDataset(models.Model): + file = models.OneToOneField(File, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_raw_dataset" + + +class HashRaw(models.Model): + raw_dataset = models.ForeignKey(RawDataset, on_delete=models.CASCADE) + hash_value = models.TextField() + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_raw_hash" + constraints = [ + models.UniqueConstraint( + fields=["raw_dataset", "hash_value"], name="unique_raw_hash" + ) + ] + + +class UrlRaw(models.Model): + raw_dataset = models.ForeignKey(RawDataset, on_delete=models.CASCADE) + url = models.CharField(max_length=500) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_raw_url" + constraints = [ + models.UniqueConstraint( + fields=["raw_dataset", "url"], name="unique_raw_url" + ) + ] + + +class Tool(models.Model): + tool_name = models.CharField(max_length=100, unique=True) + version = models.CharField(max_length=50) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_tool" + + +class Port(models.Model): + port = models.PositiveIntegerField(unique=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_ports" + + +class TlsVersion(models.Model): + tls_version = models.CharField(max_length=50, unique=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_tls_versions" + + +class CipherSuite(models.Model): + cipher_suite = models.CharField(max_length=100, unique=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_cipher_suites" + + +class HashType(models.Model): + hash_type = models.CharField(max_length=100, unique=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_hash_types" + + +class AnalyzedDataset(models.Model): + file = models.OneToOneField(File, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_analyzed_dataset" + + +class UrlAnalyzed(models.Model): + analyzed_dataset = models.ForeignKey(AnalyzedDataset, on_delete=models.CASCADE) + url = models.CharField(max_length=500) + tool = models.ForeignKey(Tool, on_delete=models.PROTECT) + ports = models.ManyToManyField(Port) + tls_versions = models.ManyToManyField(TlsVersion) + cipher_suites = models.ManyToManyField(CipherSuite) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_analyzed_url" + constraints = [ + models.UniqueConstraint( + fields=["analyzed_dataset", "url"], name="unique_analyzed_url" + ) + ] + + +class HashAnalyzed(models.Model): + analyzed_dataset = models.ForeignKey(AnalyzedDataset, on_delete=models.CASCADE) + hash_type = models.ManyToManyField(HashType) + hash_value = models.TextField() + cracked = models.BooleanField(default=False) + cracked_value = models.CharField(max_length=255, blank=True, null=True) + crack_time = models.CharField(max_length=50, blank=True, null=True) + tool = models.ForeignKey(Tool, on_delete=models.PROTECT) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "iot_analyzed_hash" + constraints = [ + models.UniqueConstraint( + fields=["analyzed_dataset", "hash_value"], name="unique_analyzed_hash" + ) + ] + + +class FindingReport(models.Model): + file = models.OneToOneField(File, on_delete=models.CASCADE) + result_json = models.TextField() + finding_count = models.IntegerField(default=0) + max_severity = models.CharField(max_length=20, default="info") + created = models.DateTimeField(auto_now_add=True) + + def to_json(self): + return { + "id": self.id, + "file_id": self.file_id, + "finding_count": self.finding_count, + "max_severity": self.max_severity, + "created": self.created.isoformat(), + "result": json.loads(self.result_json), + } + + class Meta: + db_table = "iot_finding_report" + ordering = ["-created"] + + +class DeepAnalysisJob(models.Model): + STATE_CHOICES = [ + ("pending", "Pending"), + ("running", "Running"), + ("done", "Done"), + ("error", "Error"), + ] + + file = models.OneToOneField(File, on_delete=models.CASCADE) + state = models.CharField(max_length=20, choices=STATE_CHOICES, default="pending") + url_total = models.IntegerField(default=0) + url_done = models.IntegerField(default=0) + hash_total = models.IntegerField(default=0) + hash_done = models.IntegerField(default=0) + cracked = models.IntegerField(default=0) + message = models.TextField(blank=True, default="") + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def to_json(self): + done = self.url_done + self.hash_done + total = self.url_total + self.hash_total + return { + "file_id": self.file_id, + "state": self.state, + "url_total": self.url_total, + "url_done": self.url_done, + "hash_total": self.hash_total, + "hash_done": self.hash_done, + "cracked": self.cracked, + "progress_done": done, + "progress_total": total, + "percent": int(done * 100 / total) if total else (100 if self.state == "done" else 0), + "message": self.message, + "updated": self.updated.isoformat(), + } + + class Meta: + db_table = "iot_deep_analysis_job" + ordering = ["-created"] diff --git a/django_project/IoTAnalyzer/urls.py b/django_project/IoTAnalyzer/urls.py new file mode 100644 index 0000000..36dff7e --- /dev/null +++ b/django_project/IoTAnalyzer/urls.py @@ -0,0 +1,32 @@ +"""URL routes for the IoT PreTester 4.6 Demo API.""" + +from django.urls import path + +from . import views + +urlpatterns = [ + path("api/health", views.HealthView.as_view(), name="health"), + path("api/login", views.LoginView.as_view(), name="login"), + path("api/logout", views.LogoutView.as_view(), name="logout"), + path("api/register", views.RegisterUserView.as_view(), name="register_user"), + path("api/list-users", views.ListUsersView.as_view(), name="list_users"), + path("api/delete-user", views.DeleteUserView.as_view(), name="delete_user"), + path("api/reset-password", views.ResetPasswordView.as_view(), name="reset_password"), + path("api/create_manufact", views.CreateManufacturerView.as_view(), name="create_manufacturer"), + path("api/create_device_type", views.CreateDeviceTypeView.as_view(), name="create_device_type"), + path("api/create_device", views.CreateDeviceView.as_view(), name="create_device"), + path("api/captures", views.CreateCaptureView.as_view(), name="create_capture"), + path("api/get_all_manufacturers", views.ManufacturersView.as_view(), name="manufacturers"), + path("api/get_all_devicetypes", views.DeviceTypesView.as_view(), name="device_types"), + path("api/get_all_devices", views.DevicesView.as_view(), name="devices"), + path("api/get_device_by_id/", views.DeviceDetailView.as_view(), name="device_detail"), + path("api/get_file_by_device/", views.DeviceCapturesView.as_view(), name="device_captures"), + path("api/delete_device/", views.DeleteDeviceView.as_view(), name="delete_device"), + path("api/delete_manufacturer/", views.DeleteManufacturerView.as_view(), name="delete_manufacturer"), + path("api/delete_device_type/", views.DeleteDeviceTypeView.as_view(), name="delete_device_type"), + path("api/get_findings/", views.FindingsView.as_view(), name="findings"), + path("api/get_cloud_results/", views.CloudResultsView.as_view(), name="cloud_results"), + path("api/start_analysis/", views.StartAnalysisView.as_view(), name="start_analysis"), + path("api/get_analysis_status/", views.AnalysisStatusView.as_view(), name="analysis_status"), + path("api/get_report//", views.ReportView.as_view(), name="report"), +] diff --git a/django_project/IoTAnalyzer/user_funcs.py b/django_project/IoTAnalyzer/user_funcs.py new file mode 100644 index 0000000..5101203 --- /dev/null +++ b/django_project/IoTAnalyzer/user_funcs.py @@ -0,0 +1,80 @@ +"""User-account services for the controlled demo environment.""" + +from django.contrib.auth import authenticate, get_user_model, login, logout +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.db import transaction + +User = get_user_model() + + +@transaction.atomic +def register_user(username: str, password: str, role: str) -> dict: + username = (username or "").strip() + if not username: + raise ValidationError("Username is required.") + if role not in {"admin", "user"}: + raise ValidationError("Role must be admin or user.") + if User.objects.filter(username=username).exists(): + raise ValidationError("This username already exists.") + + validate_password(password) + is_admin = role == "admin" + User.objects.create_user( + username=username, + password=password, + is_superuser=is_admin, + is_staff=is_admin, + ) + return {"status": "success", "message": "User created successfully."} + + +@transaction.atomic +def delete_user(request, user_id: int) -> dict: + if request.user.id == user_id: + raise ValidationError("You cannot delete your own account.") + user = User.objects.get(id=user_id) + user.delete() + return {"status": "success", "message": "User deleted successfully."} + + +@transaction.atomic +def set_password(user_id: int, new_password: str) -> dict: + user = User.objects.get(id=user_id) + validate_password(new_password, user) + user.set_password(new_password) + user.save(update_fields=["password"]) + return {"status": "success", "message": "Password reset successfully."} + + +def login_user(request, username: str, password: str): + user = authenticate(request, username=(username or "").strip(), password=password) + if user is None: + return False, {"message": "Invalid username or password."} + + login(request, user) + role = "admin" if user.is_superuser else "user" + return True, { + "message": "Login successful.", + "user": {"id": user.id, "username": user.username, "role": role}, + } + + +def logout_user(request) -> dict: + logout(request) + return {"status": "success", "message": "Logout successful."} + + +def list_users() -> dict: + return { + "status": "success", + "data": [ + { + "id": user.id, + "username": user.username, + "email": user.email, + "role": "admin" if user.is_superuser else "user", + } + for user in User.objects.order_by("username") + ], + } diff --git a/django_project/IoTAnalyzer/views.py b/django_project/IoTAnalyzer/views.py new file mode 100644 index 0000000..45c0cb2 --- /dev/null +++ b/django_project/IoTAnalyzer/views.py @@ -0,0 +1,382 @@ +"""REST endpoints exposed by IoT PreTester 4.6 Demo.""" + +import json +import re +from pathlib import Path +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.files.storage import FileSystemStorage +from django.http import HttpResponse +from django.db.models.deletion import ProtectedError +from django.shortcuts import get_object_or_404 +from rest_framework import serializers, status +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from .analyze.security_analysis import collect_cloud_results +from .db_create import ( + create_capture, + create_device_type, + create_manufacturer, + create_new_device, +) +from .db_read import ( + get_all_device_types, + get_all_devices, + get_all_manufacturers, + get_device_by_id, + get_files_by_device, +) +from .models import ( + DeepAnalysisJob, + Device, + DeviceType, + File, + FindingReport, + Manufacturer, +) +from .user_funcs import ( + delete_user, + list_users, + login_user, + logout_user, + register_user, + set_password, +) + + +def _validation_error(exc: ValidationError) -> Response: + messages = getattr(exc, "messages", [str(exc)]) + return Response({"error": messages[0]}, status=status.HTTP_400_BAD_REQUEST) + + +class HealthView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + return Response({"status": "ok", "service": "IoT PreTester 4.6 Demo"}) + + +class LoginView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + username = request.data.get("username") + password = request.data.get("password") + if not username or not password: + return Response( + [False, {"message": "Username and password are required."}], + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(login_user(request, username, password)) + + +class LogoutView(APIView): + def post(self, request): + return Response(logout_user(request)) + + +class UserSerializer(serializers.Serializer): + username = serializers.CharField(max_length=150) + password = serializers.CharField(write_only=True) + role = serializers.ChoiceField(choices=["admin", "user"]) + + +class RegisterUserView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + serializer = UserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result = register_user(**serializer.validated_data) + except ValidationError as exc: + return _validation_error(exc) + return Response(result, status=status.HTTP_201_CREATED) + + +class ListUsersView(APIView): + permission_classes = [IsAdminUser] + + def get(self, request): + return Response(list_users()) + + +class DeleteUserView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + try: + user_id = int(request.data.get("id")) + return Response(delete_user(request, user_id)) + except (TypeError, ValueError): + return Response({"error": "A valid user ID is required."}, status=400) + except ObjectDoesNotExist: + return Response({"error": "The user does not exist."}, status=404) + except ValidationError as exc: + return _validation_error(exc) + + +class ResetPasswordView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + user_id = request.data.get("user_id") + password = request.data.get("new_password") + if not isinstance(password, str) or not password: + return Response({"error": "A new password is required."}, status=400) + try: + return Response(set_password(int(user_id), password)) + except (TypeError, ValueError): + return Response({"error": "A valid user ID is required."}, status=400) + except ObjectDoesNotExist: + return Response({"error": "The user does not exist."}, status=404) + except ValidationError as exc: + return _validation_error(exc) + + +class CreateManufacturerView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + try: + item = create_manufacturer(request.data.get("name")) + return Response(item.to_json(), status=status.HTTP_201_CREATED) + except ValidationError as exc: + return _validation_error(exc) + + +class CreateDeviceTypeView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + try: + item = create_device_type(request.data.get("device_type")) + return Response(item.to_json(), status=status.HTTP_201_CREATED) + except ValidationError as exc: + return _validation_error(exc) + + +class DeviceSerializer(serializers.Serializer): + device_type_id = serializers.IntegerField() + device_name = serializers.CharField(max_length=100) + serial = serializers.CharField(max_length=100) + manufacturer_id = serializers.IntegerField() + comment = serializers.CharField(max_length=255, required=False, allow_blank=True) + + +class CreateDeviceView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + serializer = DeviceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + device = create_new_device(**serializer.validated_data) + return Response(device.to_json(), status=status.HTTP_201_CREATED) + except ObjectDoesNotExist: + return Response({"error": "The manufacturer or device type does not exist."}, status=400) + except ValidationError as exc: + return _validation_error(exc) + + +class CaptureSerializer(serializers.Serializer): + capture = serializers.FileField(write_only=True) + device_id = serializers.IntegerField() + firmware_version = serializers.CharField(max_length=100) + ip_address = serializers.IPAddressField() + + def validate_capture(self, capture): + if capture.size <= 0: + raise serializers.ValidationError("The capture is empty.") + if capture.size > 512 * 1024 * 1024: + raise serializers.ValidationError("The capture exceeds the 512 MB demo limit.") + if Path(capture.name).suffix.lower() not in {".pcap", ".pcapng"}: + raise serializers.ValidationError("Only PCAP and PCAPNG captures are supported.") + return capture + + +class CreateCaptureView(APIView): + def post(self, request): + serializer = CaptureSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + values = dict(serializer.validated_data) + uploaded_capture = values.pop("capture") + suffix = Path(uploaded_capture.name).suffix.lower() + stem = re.sub(r"[^A-Za-z0-9._-]+", "_", Path(uploaded_capture.name).stem)[:80] + stored_name = f"{stem or 'capture'}-{uuid4().hex[:12]}{suffix}" + storage = FileSystemStorage(location=settings.CAPTURE_UPLOAD_DIR) + stored_name = storage.save(stored_name, uploaded_capture) + try: + capture = create_capture(filename=stored_name, **values) + except ObjectDoesNotExist: + storage.delete(stored_name) + return Response({"error": "The selected device does not exist."}, status=400) + except ValidationError as exc: + storage.delete(stored_name) + return _validation_error(exc) + except Exception: + storage.delete(stored_name) + raise + return Response( + {"status": "success", "data": capture.to_json()}, + status=status.HTTP_201_CREATED, + ) + + +class ManufacturersView(APIView): + def get(self, request): + return Response(get_all_manufacturers()) + + +class DeviceTypesView(APIView): + def get(self, request): + return Response(get_all_device_types()) + + +class DevicesView(APIView): + def get(self, request): + return Response(get_all_devices()) + + +class DeviceDetailView(APIView): + def get(self, request, device_id): + get_object_or_404(Device, id=device_id) + return Response(get_device_by_id(device_id)) + + +class DeviceCapturesView(APIView): + def get(self, request, device_id): + get_object_or_404(Device, id=device_id) + return Response(get_files_by_device(device_id)) + + +class DeleteDeviceView(APIView): + permission_classes = [IsAdminUser] + + def delete(self, request, device_id): + device = get_object_or_404(Device, id=device_id) + device.delete() + return Response({"status": "success"}) + + +class DeleteManufacturerView(APIView): + permission_classes = [IsAdminUser] + + def delete(self, request, manufacturer_id): + item = get_object_or_404(Manufacturer, id=manufacturer_id) + try: + item.delete() + except ProtectedError: + return Response( + {"error": "The manufacturer is still used by a product."}, + status=status.HTTP_409_CONFLICT, + ) + return Response({"status": "success"}) + + +class DeleteDeviceTypeView(APIView): + permission_classes = [IsAdminUser] + + def delete(self, request, device_type_id): + item = get_object_or_404(DeviceType, id=device_type_id) + try: + item.delete() + except ProtectedError: + return Response( + {"error": "The device type is still used by a product."}, + status=status.HTTP_409_CONFLICT, + ) + return Response({"status": "success"}) + + +class FindingsView(APIView): + def get(self, request, file_id): + get_object_or_404(File, id=file_id) + report = FindingReport.objects.filter(file_id=file_id).first() + if report is None: + return Response( + { + "status": "empty", + "message": "No security analysis is available for this capture.", + "summary": { + "total": 0, + "by_severity": {}, + "by_category": {}, + "highlights": [], + "top": [], + }, + "findings": [], + } + ) + return Response(json.loads(report.result_json)) + + +class CloudResultsView(APIView): + def get(self, request, file_id): + get_object_or_404(File, id=file_id) + response = Response(collect_cloud_results(file_id)) + response["Cache-Control"] = "no-store" + return response + + +class StartAnalysisView(APIView): + def post(self, request, file_id): + get_object_or_404(File, id=file_id) + try: + url_ids = [int(value) for value in (request.data.get("url_ids") or [])] + hash_ids = [int(value) for value in (request.data.get("hash_ids") or [])] + except (TypeError, ValueError): + return Response({"error": "Artifact IDs must be integers."}, status=400) + if not url_ids and not hash_ids: + return Response({"error": "Select at least one artifact."}, status=400) + + from .analyze.deep_analysis import start_deep_analysis + + start_deep_analysis(file_id, url_ids=url_ids, hash_ids=hash_ids) + return Response( + {"status": "started", "url_count": len(url_ids), "hash_count": len(hash_ids)}, + status=status.HTTP_202_ACCEPTED, + ) + + +class AnalysisStatusView(APIView): + def get(self, request, file_id): + get_object_or_404(File, id=file_id) + job = DeepAnalysisJob.objects.filter(file_id=file_id).first() + data = job.to_json() if job else { + "state": "pending", + "url_total": 0, + "url_done": 0, + "hash_total": 0, + "hash_done": 0, + "cracked": 0, + "progress_done": 0, + "progress_total": 0, + "percent": 0, + "message": "Not started.", + } + response = Response(data) + response["Cache-Control"] = "no-store" + return response + + +class ReportView(APIView): + def get(self, request, file_id, fmt): + from analyzer import FORMATS, render, result_from_dict + + fmt = fmt.lower() + if fmt not in FORMATS: + return Response({"error": f"Allowed formats: {', '.join(FORMATS)}"}, status=400) + report = get_object_or_404(FindingReport, file_id=file_id) + result = result_from_dict(json.loads(report.result_json)) + content = render(result, fmt, collect_cloud_results(file_id)) + content_types = {"md": "text/markdown", "txt": "text/plain", "json": "application/json"} + response = HttpResponse(content, content_type=content_types[fmt]) + response["Content-Disposition"] = ( + f'attachment; filename="iot-pretester-4.6-demo-report-{file_id}.{fmt}"' + ) + return response diff --git a/django_project/IoTPreTester/__init__.py b/django_project/IoTPreTester/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/IoTPreTester/asgi.py b/django_project/IoTPreTester/asgi.py new file mode 100644 index 0000000..03853b2 --- /dev/null +++ b/django_project/IoTPreTester/asgi.py @@ -0,0 +1,9 @@ +"""ASGI entry point for the demo backend.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings") + +application = get_asgi_application() diff --git a/django_project/IoTPreTester/settings.py b/django_project/IoTPreTester/settings.py new file mode 100644 index 0000000..e40586c --- /dev/null +++ b/django_project/IoTPreTester/settings.py @@ -0,0 +1,131 @@ +"""Django settings for IoT PreTester 4.6 Demo.""" + +import os +from pathlib import Path + +from django.core.exceptions import ImproperlyConfigured + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def env_bool(name: str, default: bool = False) -> bool: + return os.environ.get(name, str(default)).strip().lower() in {"1", "true", "yes"} + + +DEBUG = env_bool("DEBUG", False) +SECRET_KEY = os.environ.get("SECRET_KEY") +if not SECRET_KEY: + if DEBUG: + SECRET_KEY = "django-insecure-iot-pretester-4.6-demo-local-only" + else: + raise ImproperlyConfigured("SECRET_KEY must be configured when DEBUG is disabled.") + +ALLOWED_HOSTS = [ + host.strip() + for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + if host.strip() +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "rest_framework", + "IoTAnalyzer", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "IoTPreTester.urls" +WSGI_APPLICATION = "IoTPreTester.wsgi.application" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.environ.get("DATABASE_PATH", BASE_DIR / "db.sqlite3"), + "OPTIONS": {"timeout": 30}, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", +] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "IoTAnalyzer.authentication.DemoSessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": {"user": "20/second"}, +} + +CORS_ALLOW_ALL_ORIGINS = env_bool("CORS_ALLOW_ALL_ORIGINS", False) +CORS_ALLOWED_ORIGINS = [ + origin.strip() + for origin in os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") + if origin.strip() +] +CORS_ALLOW_CREDENTIALS = True + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +CAPTURE_UPLOAD_DIR = Path( + os.environ.get("CAPTURE_UPLOAD_DIR", BASE_DIR.parent / "uploads") +) +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +APPEND_SLASH = False + +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +X_FRAME_OPTIONS = "DENY" +SECURE_CONTENT_TYPE_NOSNIFF = True + +if not DEBUG: + SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False) + CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False) diff --git a/django_project/IoTPreTester/urls.py b/django_project/IoTPreTester/urls.py new file mode 100644 index 0000000..b747ee2 --- /dev/null +++ b/django_project/IoTPreTester/urls.py @@ -0,0 +1,9 @@ +"""Root routes for the demo backend.""" + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("IoTAnalyzer.urls")), +] diff --git a/django_project/IoTPreTester/wsgi.py b/django_project/IoTPreTester/wsgi.py new file mode 100644 index 0000000..1b5590c --- /dev/null +++ b/django_project/IoTPreTester/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI entry point used by Gunicorn.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings") + +application = get_wsgi_application() diff --git a/django_project/__init__.py b/django_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/analyzer/README.md b/django_project/analyzer/README.md new file mode 100644 index 0000000..93570f2 --- /dev/null +++ b/django_project/analyzer/README.md @@ -0,0 +1,55 @@ +# IoT PreTester 4.6 Demo analysis core + +`analyzer` is the framework-independent network-capture analysis package used by both the Django backend and the command-line interface. It accepts `.pcap` or `.pcapng` files, normalizes protocol data once, runs independent detectors, and renders Markdown, text, or JSON reports. + +## Main modules + +- `context.py` defines the normalized `CaptureContext` records. +- `extract_pyshark.py` builds the context through tshark/pyshark. +- `detectors/` contains one detection concern per module. +- `engine.py` runs detectors and assembles the final result. +- `scoring.py` deduplicates and prioritizes findings. +- `provenance.py` attaches artifacts and packet/stream references. +- `artifacts.py` extracts URL and hash artifacts from the shared context. +- `enrich/` provides optional URL probing and hash recovery. +- `reporting/` renders `md`, `txt`, and `json` output. + +## Command line + +From `django_project/`: + +```bash +python -m analyzer.cli ../demo_files/demo_capture.pcapng +python -m analyzer.cli capture.pcapng -f json -o report.json +python -m analyzer.cli capture.pcapng -f txt --packet-limit 100000 +``` + +Exit codes are `0` for no high/critical findings, `2` when at least one high/critical finding exists, and `1` for execution errors. + +## Python API + +```python +from analyzer import AnalysisEngine, render +from analyzer.context import CaptureContext + +result = AnalysisEngine().analyze_pcap("capture.pcapng") +print(render(result, "md")) + +# Detector logic can also be tested without tshark. +synthetic_result = AnalysisEngine().analyze_context(CaptureContext()) +``` + +## Tests + +```bash +python -m unittest analyzer.tests.test_analyzer -v +``` + +The unit suite uses synthetic contexts and does not require tshark. + +## Adding a detector + +1. Add a focused module under `detectors/`. +2. Inherit from `BaseDetector`, apply `@register`, and return shared `Finding` objects from `detect()`. +3. Import the module from `detectors/__init__.py`. +4. Add focused tests under `tests/`. diff --git a/django_project/analyzer/__init__.py b/django_project/analyzer/__init__.py new file mode 100644 index 0000000..7aded83 --- /dev/null +++ b/django_project/analyzer/__init__.py @@ -0,0 +1,37 @@ +"""Framework-independent analysis core for IoT PreTester 4.6 Demo. + +The package is independent of Django and supports both backend and CLI use. +The pyshark/tshark extractor is loaded only when a capture is analyzed. + +Headless example: + from analyzer import AnalysisEngine, render + result = AnalysisEngine().analyze_pcap("capture.pcapng") + print(render(result, "md")) + +Synthetic-context example without tshark: + from analyzer import AnalysisEngine + from analyzer.context import CaptureContext + result = AnalysisEngine().analyze_context(CaptureContext(...)) +""" + +from __future__ import annotations + +from .findings import Finding, Category, Severity, Confidence +from .context import CaptureContext +from .engine import AnalysisEngine, AnalysisResult, result_from_dict +from .reporting import render, FORMATS +from .artifacts import ( + UrlArtifact, HashArtifact, extract_urls, extract_hashes, identify_hash_mode, +) + +# Importing the package registers every detector. +from . import detectors # noqa: F401 + +__version__ = "4.6.0-demo.0" + +__all__ = [ + "Finding", "Category", "Severity", "Confidence", + "CaptureContext", "AnalysisEngine", "AnalysisResult", "result_from_dict", + "render", "FORMATS", "__version__", + "UrlArtifact", "HashArtifact", "extract_urls", "extract_hashes", "identify_hash_mode", +] diff --git a/django_project/analyzer/artifacts.py b/django_project/analyzer/artifacts.py new file mode 100644 index 0000000..b698fde --- /dev/null +++ b/django_project/analyzer/artifacts.py @@ -0,0 +1,237 @@ +"""Extract endpoint and crackable hash artifacts from a capture context. + +Extraction is independent of Django, pyshark, and I/O. Active endpoint probing +and hash cracking remain isolated in ``analyzer.enrich``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + +from .context import CaptureContext + + +# --------------------------------------------------------------------------- # +# Artifact data models +# --------------------------------------------------------------------------- # + +@dataclass +class UrlArtifact: + """A domain, host, or URL observed in the capture.""" + + value: str + kind: str = "unknown" # "dns" | "sni" | "http_host" | "payload" + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + timestamp: Optional[float] = None + packet_number: Optional[int] = None + # Passive observations are preferred over active probing results. + observed_ports: set[int] = field(default_factory=set) + observed_tls: set[str] = field(default_factory=set) + + def to_dict(self) -> dict: + return { + "value": self.value, "kind": self.kind, + "src_ip": self.src_ip, "dst_ip": self.dst_ip, + "timestamp": self.timestamp, "packet_number": self.packet_number, + "observed_ports": sorted(self.observed_ports), + "observed_tls": sorted(self.observed_tls), + } + + +@dataclass +class HashArtifact: + """A hash or crackable authentication artifact from the capture.""" + + value: str # Raw hash or complete hashcat line + hashcat_mode: Optional[int] = None + hash_format: Optional[str] = None # Human-readable format name + kind: str = "raw_hex" # "raw_hex" | "netntlmv2" | "krb5asrep" | "krb5tgs" | "wpa" | "digest" + account: Optional[str] = None + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + timestamp: Optional[float] = None + packet_number: Optional[int] = None + + def to_dict(self) -> dict: + return { + "value": self.value, "hashcat_mode": self.hashcat_mode, + "hash_format": self.hash_format, "kind": self.kind, + "account": self.account, "src_ip": self.src_ip, "dst_ip": self.dst_ip, + "timestamp": self.timestamp, "packet_number": self.packet_number, + } + + +# --------------------------------------------------------------------------- # +# Hashcat mode detection +# --------------------------------------------------------------------------- # + +# Hex length to hashcat mode and format name. +_LEN_MODES = { + 32: (0, "md5"), + 40: (100, "sha1"), + 56: (300, "sha224"), + 64: (1400, "sha256"), + 96: (6100, "sha384"), + 128: (1700, "sha512"), +} + +# Structured format name to hashcat mode. +_FORMAT_MODES = { + "netntlmv1": 5500, + "netntlmv2": 5600, + "krb5asrep": 18200, + "krb5tgs": 13100, + "wpa": 22000, + "eapol": 22000, +} + +_NETNTLMV2_RE = re.compile(r"^[^:]+::[^:]*:[0-9a-fA-F]{16}:[0-9a-fA-F]{32}:[0-9a-fA-F]+$") +_NETNTLMV1_RE = re.compile(r"^[^:]+::[^:]*:[0-9a-fA-F]{48}:[0-9a-fA-F]{48}:[0-9a-fA-F]{16}$") + + +def identify_hash_mode(value: str, hint_format: Optional[str] = None): + """Return a hashcat mode and format using structure before hex length.""" + v = (value or "").strip() + if not v: + return None, None + + if hint_format: + base = str(hint_format).split("(")[0].strip().lower() + if base in _FORMAT_MODES: + return _FORMAT_MODES[base], hint_format + + low = v.lower() + if low.startswith("$krb5asrep$"): + return 18200, "krb5asrep" + if low.startswith("$krb5tgs$"): + return 13100, "krb5tgs" + if low.startswith("$wpapsk$") or low.startswith("wpa*") or low.startswith("$wpa$"): + return 22000, "wpa" + if _NETNTLMV2_RE.match(v): + return 5600, "NetNTLMv2" + if _NETNTLMV1_RE.match(v): + return 5500, "NetNTLMv1" + + if all(c in "0123456789abcdefABCDEF" for c in v) and len(v) in _LEN_MODES: + return _LEN_MODES[len(v)] + return None, None + + +def _kind_from_mode(mode: Optional[int]) -> str: + return { + 5500: "netntlmv1", 5600: "netntlmv2", + 18200: "krb5asrep", 13100: "krb5tgs", 22000: "wpa", + }.get(mode, "raw_hex") + + +# --------------------------------------------------------------------------- # +# Endpoint extraction +# --------------------------------------------------------------------------- # + +_URL_RE = re.compile( + r"\b(?:" + r"(?:https?://|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})" # URL or www host + r"|" + r"(?:[a-zA-Z0-9-]+\.){2,}[a-zA-Z]{2,6}" # Bare multi-label domain + r"|" + r"https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}" # URL with IPv4 host + r")" + r"(?:/[a-zA-Z0-9._%/-]*)?\b" +) + + +def _normalize_host(value: Optional[str]) -> Optional[str]: + if not value: + return None + v = str(value).strip().lower() + v = v.rstrip(".") + return v or None + + +def extract_urls(ctx: CaptureContext) -> list[UrlArtifact]: + """Collect endpoints and attach passively observed ports and TLS versions.""" + by_value: dict[str, UrlArtifact] = {} + + def add(value, kind, **kw) -> Optional[UrlArtifact]: + v = _normalize_host(value) + if not v: + return None + art = by_value.get(v) + if art is None: + art = UrlArtifact(value=v, kind=kind, **kw) + by_value[v] = art + return art + + for q in ctx.dns_queries: + add(q.query_name, "dns", src_ip=q.src_ip, dst_ip=q.dst_ip, + timestamp=q.timestamp, packet_number=q.packet_number) + + for t in ctx.tls_sessions: + if t.sni: + art = add(t.sni, "sni", src_ip=t.src_ip, dst_ip=t.dst_ip, + timestamp=t.timestamp, packet_number=t.packet_number) + if art: + if t.dst_port: + art.observed_ports.add(t.dst_port) + if t.version: + art.observed_tls.add(t.version) + + for r in ctx.http_requests: + if r.host: + art = add(r.host, "http_host", src_ip=r.src_ip, dst_ip=r.dst_ip, + timestamp=r.timestamp, packet_number=r.packet_number) + if art and r.dst_port: + art.observed_ports.add(r.dst_port) + + for p in ctx.text_payloads: + text = p.get("text") or "" + for m in _URL_RE.findall(text): + add(m, "payload", src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"), + timestamp=p.get("timestamp"), packet_number=p.get("packet_number")) + + return list(by_value.values()) + + +# --------------------------------------------------------------------------- # +# Hash extraction +# --------------------------------------------------------------------------- # + +_HASH_HEX_RE = re.compile(r"\b[a-fA-F0-9]{32,64}\b") + + +def extract_hashes(ctx: CaptureContext) -> list[HashArtifact]: + """Collect structured authentication material and raw hexadecimal hashes.""" + out: list[HashArtifact] = [] + seen: set[str] = set() + + def add(value, *, mode, fmt, kind, **kw) -> None: + if not value or value in seen: + return + seen.add(value) + out.append(HashArtifact(value=value, hashcat_mode=mode, hash_format=fmt, + kind=kind, **kw)) + + # Structured material already reconstructed by the packet extractor. + for am in ctx.auth_material: + if getattr(am, "hashcat", None): + mode = am.hashcat_mode + fmt = am.hash_format + if mode is None: + mode, fmt = identify_hash_mode(am.hashcat, hint_format=fmt) + add(am.hashcat, mode=mode, fmt=fmt, kind=_kind_from_mode(mode), + account=am.account, src_ip=am.src_ip, dst_ip=am.dst_ip, + timestamp=am.timestamp, packet_number=am.packet_number) + + # Raw 32–64 character hex values use a mode only when the length is known. + for p in ctx.text_payloads: + text = p.get("text") or "" + for m in _HASH_HEX_RE.findall(text): + mode, fmt = identify_hash_mode(m) + add(m.lower(), mode=mode, fmt=fmt, kind="raw_hex", + src_ip=p.get("src_ip"), dst_ip=p.get("dst_ip"), + timestamp=p.get("timestamp"), packet_number=p.get("packet_number")) + + return out diff --git a/django_project/analyzer/cli.py b/django_project/analyzer/cli.py new file mode 100644 index 0000000..327ae88 --- /dev/null +++ b/django_project/analyzer/cli.py @@ -0,0 +1,72 @@ +"""Headless CLI for IoT PreTester 4.6 Demo. + +Examples: + python -m analyzer.cli capture.pcapng + python -m analyzer.cli capture.pcapng --format json -o report.json + python -m analyzer.cli capture.pcapng --format txt --packet-limit 50000 + +Exit codes: + 0 Analysis completed without high or critical findings + 2 Analysis completed with at least one high or critical finding + 1 Capture or runtime dependency error +""" + +from __future__ import annotations + +import argparse +import sys + +from . import __version__ +from .engine import AnalysisEngine +from .findings import Severity +from .reporting import render, FORMATS + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="analyzer", + description="Analyze the security of a PCAP or PCAPNG network capture.", + ) + p.add_argument("capture", help="Path to a .pcap or .pcapng file") + p.add_argument("-f", "--format", choices=FORMATS, default="md", + help="Output format (default: md)") + p.add_argument("-o", "--output", default=None, + help="Output file (default: standard output)") + p.add_argument("--packet-limit", type=int, default=None, + help="Maximum packets to read from a large capture") + p.add_argument( + "--version", + action="version", + version=f"IoT PreTester 4.6 Demo analyzer {__version__}", + ) + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + + try: + result = AnalysisEngine().analyze_pcap(args.capture, packet_limit=args.packet_limit) + except FileNotFoundError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + report = render(result, args.format) + + if args.output: + with open(args.output, "w", encoding="utf-8") as fh: + fh.write(report) + print(f"Report written: {args.output} " + f"({result.meta.get('finding_count', 0)} Findings)", file=sys.stderr) + else: + print(report) + + worst = max((f.severity.rank for f in result.findings), default=0) + return 2 if worst >= Severity.HIGH.rank else 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/django_project/analyzer/context.py b/django_project/analyzer/context.py new file mode 100644 index 0000000..2be058c --- /dev/null +++ b/django_project/analyzer/context.py @@ -0,0 +1,249 @@ +"""Normalized capture data consumed by every detector. + +The module is independent of Django and pyshark so detector logic can be tested +with synthetic data. ``extract_pyshark.py`` performs the actual PCAP extraction. +""" + +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass, field +from typing import Optional + + +# --------------------------------------------------------------------------- # +# Normalized records +# --------------------------------------------------------------------------- # + +@dataclass +class Host: + ip: str + macs: set[str] = field(default_factory=set) + hostnames: set[str] = field(default_factory=set) + bytes_sent: int = 0 + bytes_received: int = 0 + ports_offered: set[int] = field(default_factory=set) # Ports on which the host responds + ports_contacted: set[int] = field(default_factory=set) # Ports contacted by the host + + @property + def is_private(self) -> bool: + return _is_private(self.ip) + + +@dataclass +class Conversation: + """A five-tuple flow aggregated between two endpoints.""" + + src_ip: str + dst_ip: str + src_port: Optional[int] + dst_port: Optional[int] + protocol: str # For example, "tcp" or "udp" + packets: int = 0 + bytes: int = 0 + bytes_src_to_dst: int = 0 + bytes_dst_to_src: int = 0 + first_seen: Optional[float] = None + last_seen: Optional[float] = None + stream_id: Optional[int] = None + timestamps: list[float] = field(default_factory=list) # Used to detect beaconing + packet_numbers: list[int] = field(default_factory=list) # Bounded evidence packet list + + +@dataclass +class DnsQuery: + query_name: str + qtype: Optional[str] = None + response_ips: list[str] = field(default_factory=list) + rcode: Optional[str] = None # For example, "NOERROR" or "NXDOMAIN" + timestamp: Optional[float] = None + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + resolver: Optional[str] = None + packet_number: Optional[int] = None + + +@dataclass +class HttpRequest: + method: Optional[str] = None + host: Optional[str] = None + uri: Optional[str] = None + user_agent: Optional[str] = None + authorization: Optional[str] = None # Authorization header value + cookies: Optional[str] = None + headers: dict[str, str] = field(default_factory=dict) + body: Optional[str] = None + request_bytes: int = 0 + is_tls: bool = False # True when only TLS metadata is visible + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + dst_port: Optional[int] = None + timestamp: Optional[float] = None + stream_id: Optional[int] = None + packet_number: Optional[int] = None + + +@dataclass +class HttpResponse: + status_code: Optional[int] = None + content_type: Optional[str] = None + server: Optional[str] = None + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + timestamp: Optional[float] = None + stream_id: Optional[int] = None + packet_number: Optional[int] = None + + +@dataclass +class TlsSession: + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + dst_port: Optional[int] = 443 + version: Optional[str] = None # For example, "TLS 1.2" or "TLS 1.0" + cipher: Optional[str] = None # Cipher-suite name + sni: Optional[str] = None + cert_subject_cn: Optional[str] = None + cert_issuer_cn: Optional[str] = None + cert_not_after: Optional[str] = None + cert_self_signed: Optional[bool] = None + cert_expired: Optional[bool] = None + timestamp: Optional[float] = None + stream_id: Optional[int] = None + packet_number: Optional[int] = None + + +@dataclass +class AuthMaterial: + """Hashes, challenge-response material, and authentication tickets.""" + + kind: str # "ntlmssp", "netntlmv2", "kerberos_asrep", ... + value: Optional[str] = None + account: Optional[str] = None + domain: Optional[str] = None + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + protocol: Optional[str] = None + timestamp: Optional[float] = None + packet_number: Optional[int] = None + + # Crackable material, when reconstruction is possible. + hashcat: Optional[str] = None # Complete hashcat input line + hashcat_mode: Optional[int] = None # For example, 5600 (NetNTLMv2) + hash_format: Optional[str] = None # Human-readable format name + server_challenge: Optional[str] = None # NTLMSSP server challenge (hex) + nt_response: Optional[str] = None # NTLMv2 response (hex) + etype: Optional[str] = None # Kerberos encryption type + + +@dataclass +class ArpEvent: + sender_ip: Optional[str] = None + sender_mac: Optional[str] = None + target_ip: Optional[str] = None + opcode: Optional[int] = None # 1=request, 2=reply + is_gratuitous: bool = False + timestamp: Optional[float] = None + packet_number: Optional[int] = None + + +@dataclass +class FileTransfer: + filename: Optional[str] = None + extension: Optional[str] = None + content_type: Optional[str] = None + size: Optional[int] = None + protocol: Optional[str] = None # "http", "ftp-data", "smb", "tftp" + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + direction: Optional[str] = None # "download" / "upload" + timestamp: Optional[float] = None + stream_id: Optional[int] = None + packet_number: Optional[int] = None + + +@dataclass +class PlaintextAuth: + """Clear-text authentication observed in an application protocol.""" + + protocol: str + username: Optional[str] = None + password: Optional[str] = None + src_ip: Optional[str] = None + dst_ip: Optional[str] = None + dst_port: Optional[int] = None + timestamp: Optional[float] = None + stream_id: Optional[int] = None + packet_number: Optional[int] = None + + +# --------------------------------------------------------------------------- # +# Central capture context +# --------------------------------------------------------------------------- # + +@dataclass +class CaptureContext: + """All detector input extracted during a single capture pass.""" + + path: Optional[str] = None + packet_count: int = 0 + start_time: Optional[float] = None + end_time: Optional[float] = None + + hosts: dict[str, Host] = field(default_factory=dict) + conversations: list[Conversation] = field(default_factory=list) + dns_queries: list[DnsQuery] = field(default_factory=list) + http_requests: list[HttpRequest] = field(default_factory=list) + http_responses: list[HttpResponse] = field(default_factory=list) + tls_sessions: list[TlsSession] = field(default_factory=list) + auth_material: list[AuthMaterial] = field(default_factory=list) + arp_events: list[ArpEvent] = field(default_factory=list) + file_transfers: list[FileTransfer] = field(default_factory=list) + plaintext_auth: list[PlaintextAuth] = field(default_factory=list) + + # protocol_stats: protocol-name -> packet count + protocol_stats: dict[str, int] = field(default_factory=dict) + # Generic text payloads with packet metadata for secret detection. + text_payloads: list[dict] = field(default_factory=list) + + @property + def duration(self) -> float: + if self.start_time is None or self.end_time is None: + return 0.0 + return max(0.0, self.end_time - self.start_time) + + def host(self, ip: str) -> Host: + h = self.hosts.get(ip) + if h is None: + h = Host(ip=ip) + self.hosts[ip] = h + return h + + def is_external(self, ip: Optional[str]) -> bool: + """Return whether an address is globally routable.""" + if not ip: + return False + return not _is_private(ip) + + def protocol_count(self, *names: str) -> int: + """Return a case-insensitive packet total for the requested protocols.""" + wanted = {n.lower() for n in names} + return sum( + count for proto, count in self.protocol_stats.items() + if str(proto).lower() in wanted + ) + + +def _is_private(ip: str) -> bool: + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_reserved + or addr.is_unspecified + ) diff --git a/django_project/analyzer/crackformats.py b/django_project/analyzer/crackformats.py new file mode 100644 index 0000000..ab4644d --- /dev/null +++ b/django_project/analyzer/crackformats.py @@ -0,0 +1,122 @@ +"""Pure helpers that format captured authentication material for hashcat. + +Supported hashcat modes: + 5500 NetNTLMv1 + 5600 NetNTLMv2 + 18200 Kerberos AS-REP (etype 23) + 13100 Kerberos TGS-REP (etype 23) +""" + +from __future__ import annotations + +import base64 +from typing import Optional + + +def _hex(s: Optional[str]) -> str: + """Normalize a hexadecimal string and remove separators.""" + if not s: + return "" + return "".join(c for c in str(s).lower() if c in "0123456789abcdef") + + +# --------------------------------------------------------------------------- # +# NTLM / NetNTLM +# --------------------------------------------------------------------------- # + +def build_netntlmv2(user, domain, server_challenge, nt_response) -> Optional[dict]: + """NetNTLMv2 (hashcat -m 5600). + + Format: user::domain:serverchallenge:NTproofstr:blob + The NT response contains a 16-byte proof followed by the remaining blob. + """ + chal = _hex(server_challenge) + nt = _hex(nt_response) + if not user or len(chal) != 16 or len(nt) < 32: + return None + ntproof, blob = nt[:32], nt[32:] + line = f"{user}::{domain or ''}:{chal}:{ntproof}:{blob}" + return {"hashcat": line, "hashcat_mode": 5600, "format": "NetNTLMv2"} + + +def build_netntlmv1(user, host, server_challenge, lm_response, nt_response) -> Optional[dict]: + """NetNTLMv1 (hashcat -m 5500). + + Format: user::host:LMresponse:NTresponse:serverchallenge + """ + chal = _hex(server_challenge) + nt = _hex(nt_response) + lm = _hex(lm_response) or nt + if not user or len(chal) != 16 or len(nt) < 32: + return None + line = f"{user}::{host or ''}:{lm}:{nt}:{chal}" + return {"hashcat": line, "hashcat_mode": 5500, "format": "NetNTLMv1"} + + +# --------------------------------------------------------------------------- # +# Kerberos +# --------------------------------------------------------------------------- # + +def build_krb5_asrep(user, realm, etype, cipher) -> Optional[dict]: + """Kerberos AS-REP Roasting (hashcat -m 18200, etype 23). + + Format: $krb5asrep$$user@REALM:$ + cipher = checksum (16 Byte) + edata. + """ + ci = _hex(cipher) + if not user or len(ci) <= 32: + return None + et = str(etype or 23) + checksum, edata = ci[:32], ci[32:] + realm_u = (realm or "").upper() + line = f"$krb5asrep${et}${user}@{realm_u}:{checksum}${edata}" + mode = 18200 if et == "23" else None + return {"hashcat": line, "hashcat_mode": mode, "format": f"krb5asrep(etype={et})"} + + +def build_krb5_tgsrep(user, realm, spn, etype, cipher) -> Optional[dict]: + """Kerberoasting / TGS-REP (hashcat -m 13100, etype 23). + + Format: $krb5tgs$$*user$realm$spn*$$ + """ + ci = _hex(cipher) + if not user or len(ci) <= 32: + return None + et = str(etype or 23) + checksum, edata = ci[:32], ci[32:] + realm_u = (realm or "").upper() + line = f"$krb5tgs${et}$*{user}${realm_u}${spn or ''}*${checksum}${edata}" + mode = 13100 if et == "23" else None + return {"hashcat": line, "hashcat_mode": mode, "format": f"krb5tgs(etype={et})"} + + +# --------------------------------------------------------------------------- # +# SMTP AUTH is clear text with Base64 encoding only. +# --------------------------------------------------------------------------- # + +def _b64(token: str) -> Optional[str]: + try: + return base64.b64decode(str(token).strip() + "===").decode("utf-8", "ignore") + except Exception: + return None + + +def decode_smtp_auth_plain(token: str) -> Optional[tuple[Optional[str], Optional[str]]]: + """Decode an AUTH PLAIN token into username and password.""" + raw = _b64(token) + if raw is None: + return None + parts = raw.split("\x00") + if len(parts) >= 3: + return parts[1] or None, parts[2] or None + if len(parts) == 2: + return parts[0] or None, parts[1] or None + return None + + +def decode_smtp_auth_login(user_token: Optional[str], + pass_token: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Decode the separately encoded AUTH LOGIN username and password.""" + user = _b64(user_token) if user_token else None + pw = _b64(pass_token) if pass_token else None + return user, pw diff --git a/django_project/analyzer/detectors/__init__.py b/django_project/analyzer/detectors/__init__.py new file mode 100644 index 0000000..8780b8c --- /dev/null +++ b/django_project/analyzer/detectors/__init__.py @@ -0,0 +1,17 @@ +"""Import and register the ordered default detector set.""" + +from .base import BaseDetector, register, build_all, all_detector_classes # noqa: F401 + +# Import order defines the stable detector order in reports. +from . import plaintext_credentials # 1 +from . import password_hashes # 2 +from . import tokens_secrets # 3 +from . import malware_beaconing # 4 +from . import exfiltration # 5 +from . import mitm # 6 +from . import insecure_protocols # 7 +from . import tls_weakness # 8 +from . import dns_anomaly # 9 +from . import bruteforce_scan_filetransfer # 10 + +__all__ = ["BaseDetector", "register", "build_all", "all_detector_classes"] diff --git a/django_project/analyzer/detectors/base.py b/django_project/analyzer/detectors/base.py new file mode 100644 index 0000000..c1bd669 --- /dev/null +++ b/django_project/analyzer/detectors/base.py @@ -0,0 +1,45 @@ +"""Detector base class and registration mechanism.""" + +from __future__ import annotations + +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding + + +class BaseDetector: + #: Short unique name referenced by findings and reports. + name: str = "BaseDetector" + #: Human-readable description of the detector scope. + description: str = "" + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: # pragma: no cover + raise NotImplementedError + + def _tag(self, findings: Iterable[Finding]) -> list[Finding]: + out = [] + for f in findings: + f.detector = self.name + out.append(f) + return out + + +# ---- Registry ------------------------------------------------------------- # + +_REGISTRY: dict[str, type[BaseDetector]] = {} + + +def register(cls: type[BaseDetector]) -> type[BaseDetector]: + """Register a detector class.""" + _REGISTRY[cls.name] = cls + return cls + + +def all_detector_classes() -> list[type[BaseDetector]]: + return list(_REGISTRY.values()) + + +def build_all() -> list[BaseDetector]: + """Instantiate all registered detector classes.""" + return [cls() for cls in _REGISTRY.values()] diff --git a/django_project/analyzer/detectors/bruteforce_scan_filetransfer.py b/django_project/analyzer/detectors/bruteforce_scan_filetransfer.py new file mode 100644 index 0000000..ca7d96f --- /dev/null +++ b/django_project/analyzer/detectors/bruteforce_scan_filetransfer.py @@ -0,0 +1,154 @@ +"""Detect brute force, scanning, and suspicious file transfers.""" + +from __future__ import annotations + +import os +from collections import defaultdict +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +# File extensions that warrant closer inspection. +_SUSPICIOUS_EXT = { + ".exe", ".dll", ".ps1", ".vbs", ".js", ".jar", ".bat", ".cmd", ".scr", + ".hta", ".lnk", ".docm", ".xlsm", ".pptm", ".zip", ".rar", ".7z", ".iso", ".elf", +} +_PORTSCAN_PORTS = 15 +_HOSTSWEEP_HOSTS = 15 +_FAILED_LOGINS = 5 + + +@register +class BruteforceScanFileTransferDetector(BaseDetector): + name = "BruteforceScanFileTransferDetector" + description = "Detects scans, repeated authentication failures, and risky downloads." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + ports_per_pair: dict[tuple, set[int]] = defaultdict(set) + hosts_per_src: dict[str, set[str]] = defaultdict(set) + packets_per_pair: dict[tuple, list] = defaultdict(list) + packets_per_src: dict[str, list] = defaultdict(list) + for conv in ctx.conversations: + if conv.src_ip and conv.dst_ip: + if conv.dst_port: + ports_per_pair[(conv.src_ip, conv.dst_ip)].add(conv.dst_port) + hosts_per_src[conv.src_ip].add(conv.dst_ip) + if conv.packet_numbers: + packets_per_pair[(conv.src_ip, conv.dst_ip)].append(conv.packet_numbers[0]) + packets_per_src[conv.src_ip].append(conv.packet_numbers[0]) + + for (src, dst), ports in ports_per_pair.items(): + if len(ports) >= _PORTSCAN_PORTS: + findings.append(Finding( + title="Port scan detected", + category=Category.BRUTEFORCE_SCAN_FILETRANSFER, + severity=Severity.MEDIUM, confidence=Confidence.MEDIUM, + source_ip=src, destination_ip=dst, + risk_description=( + "One host contacted many ports on a target, which is characteristic " + "of network reconnaissance." + ), + recommendation="Review the source and block unauthorized external scanners.", + evidence={"distinct_ports": len(ports), "dedup_key": f"portscan:{src}:{dst}"}, + related_packets=sorted(set(packets_per_pair.get((src, dst), [])))[:8], + )) + + for src, dsts in hosts_per_src.items(): + if len(dsts) >= _HOSTSWEEP_HOSTS: + findings.append(Finding( + title="Host sweep detected", + category=Category.BRUTEFORCE_SCAN_FILETRANSFER, + severity=Severity.MEDIUM, confidence=Confidence.LOW, + source_ip=src, + risk_description=( + "One host contacted many different targets, which may indicate a " + "network sweep or worm behavior." + ), + recommendation="Review the source and investigate internal hosts for compromise.", + evidence={"distinct_hosts": len(dsts), "dedup_key": f"sweep:{src}"}, + related_packets=sorted(set(packets_per_src.get(src, [])))[:8], + )) + + # Repeated HTTP authorization failures are a brute-force indicator. + http_401: dict[tuple, int] = defaultdict(int) + http_401_pkts: dict[tuple, list] = defaultdict(list) + for resp in ctx.http_responses: + if resp.status_code in (401, 403): + http_401[(resp.dst_ip, resp.src_ip)] += 1 + if resp.packet_number: + http_401_pkts[(resp.dst_ip, resp.src_ip)].append(resp.packet_number) + for (client, server), count in http_401.items(): + if count >= _FAILED_LOGINS: + findings.append(Finding( + title="Repeated failed HTTP authentication", + category=Category.BRUTEFORCE_SCAN_FILETRANSFER, + severity=Severity.MEDIUM, confidence=Confidence.MEDIUM, + source_ip=client, destination_ip=server, protocol="http", + risk_description=( + "Many HTTP 401 or 403 responses may indicate a password guessing attack." + ), + recommendation="Enable rate limiting or lockout controls and review the source.", + evidence={"failed_responses": count, "dedup_key": f"bruteforce:{client}:{server}"}, + related_packets=sorted(set(http_401_pkts.get((client, server), [])))[:8], + )) + + seen_files: set[str] = set() + for req in ctx.http_requests: + uri = (req.uri or "").split("?")[0] + ext = os.path.splitext(uri)[1].lower() + if ext in _SUSPICIOUS_EXT: + key = f"file:{req.host}:{uri}" + if key in seen_files: + continue + seen_files.add(key) + findings.append(Finding( + title=f"Potentially harmful file download ({ext})", + category=Category.BRUTEFORCE_SCAN_FILETRANSFER, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + source_ip=req.src_ip, destination_ip=req.dst_ip, protocol="http", port=req.dst_port, + timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp, + risk_description=( + "An executable or script-capable file was transferred over HTTP and " + "may represent a malware download." + ), + recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.", + evidence={"host": req.host, "uri": uri, "extension": ext, "dedup_key": key}, + related_packets=[p for p in [req.packet_number] if p], + )) + + # Response metadata can reveal risky files absent from the request URI. + for ft in ctx.file_transfers: + ext = (ft.extension or "").lower() + ct = (ft.content_type or "").lower() + is_executable_ct = any( + m in ct for m in ("x-msdownload", "x-dosexec", "x-executable", + "portable-executable", "x-elf", "x-msi") + ) + if ext not in _SUSPICIOUS_EXT and not is_executable_ct: + continue + key = f"ft:{ft.dst_ip}:{ft.filename or ct}" + if key in seen_files: + continue + seen_files.add(key) + label = ext or ct or "binary file" + findings.append(Finding( + title=f"Potentially harmful file download ({label})", + category=Category.BRUTEFORCE_SCAN_FILETRANSFER, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + source_ip=ft.dst_ip, destination_ip=ft.src_ip, protocol=ft.protocol, + timestamp_first_seen=ft.timestamp, timestamp_last_seen=ft.timestamp, + risk_description=( + "An executable or script-capable file was transferred and may represent " + "a malware download." + ), + recommendation="Analyze the file in a sandbox and check its hash against threat intelligence.", + evidence={"filename": ft.filename, "content_type": ft.content_type, + "extension": ext or None, "dedup_key": key}, + related_packets=[p for p in [ft.packet_number] if p], + )) + + return self._tag(findings) diff --git a/django_project/analyzer/detectors/dns_anomaly.py b/django_project/analyzer/detectors/dns_anomaly.py new file mode 100644 index 0000000..2f20596 --- /dev/null +++ b/django_project/analyzer/detectors/dns_anomaly.py @@ -0,0 +1,154 @@ +"""Detect anomalous DNS activity and potential DNS tunneling.""" + +from __future__ import annotations + +import math +from collections import defaultdict +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +_LONG_LABEL = 40 +_ENTROPY_MIN_LEN = 12 +_HIGH_ENTROPY = 3.2 +_NXDOMAIN_RATIO = 0.3 +_MANY_SUBDOMAINS = 30 +_TUNNEL_QTYPES = {"TXT", "NULL"} +_TXT_TUNNEL_MIN = 20 +_TXT_TUNNEL_RATIO = 0.5 + + +@register +class DnsAnomalyDetector(BaseDetector): + name = "DnsAnomalyDetector" + description = "Detects DNS tunneling, DGA-like names, and high NXDOMAIN rates." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + if not ctx.dns_queries: + return findings + + nx = 0 + total = 0 + txt_like = 0 + subdomains_per_domain: dict[str, set[str]] = defaultdict(set) + packets_per_domain: dict[str, list] = defaultdict(list) + nx_packets: list = [] + txt_packets: list = [] + flagged_random: set[str] = set() + + for q in ctx.dns_queries: + name = (q.query_name or "").rstrip(".").lower() + if not name: + continue + total += 1 + if (q.rcode or "").upper() == "NXDOMAIN": + nx += 1 + if q.packet_number: + nx_packets.append(q.packet_number) + if (q.qtype or "").upper() in _TUNNEL_QTYPES: + txt_like += 1 + if q.packet_number: + txt_packets.append(q.packet_number) + + labels = name.split(".") + reg_domain = ".".join(labels[-2:]) if len(labels) >= 2 else name + if len(labels) > 2: + subdomains_per_domain[reg_domain].add(".".join(labels[:-2])) + if q.packet_number: + packets_per_domain[reg_domain].append(q.packet_number) + + # Long or high-entropy labels may indicate a DGA or tunneling channel. + for label in labels[:-2] or labels: + if len(label) >= _LONG_LABEL or ( + len(label) >= _ENTROPY_MIN_LEN and _entropy(label) >= _HIGH_ENTROPY + ): + if reg_domain not in flagged_random: + flagged_random.add(reg_domain) + findings.append(Finding( + title="DNS query suspicious for DGA or tunneling activity", + category=Category.DNS_ANOMALY, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + source_ip=q.src_ip, destination_ip=q.dst_ip, protocol="dns", port=53, + timestamp_first_seen=q.timestamp, timestamp_last_seen=q.timestamp, + risk_description=( + "Long or random-looking subdomains are characteristic of DNS " + "tunneling and malware domain-generation algorithms." + ), + recommendation=( + "Check the domain against threat intelligence and enable DNS " + "tunneling detection and egress controls." + ), + evidence={"query_name": name, "registered_domain": reg_domain, + "label_entropy": round(_entropy(label), 2), + "dedup_key": f"dga:{reg_domain}"}, + related_packets=[p for p in [q.packet_number] if p], + )) + break + + for domain, subs in subdomains_per_domain.items(): + if len(subs) >= _MANY_SUBDOMAINS: + findings.append(Finding( + title=f"Unusually many subdomains under {domain}", + category=Category.DNS_ANOMALY, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + protocol="dns", port=53, + risk_description=( + "A large number of unique subdomains under one domain is a strong " + "indicator of DNS tunneling or exfiltration." + ), + recommendation="Review or block the domain and enable tunneling detection.", + evidence={"registered_domain": domain, "unique_subdomains": len(subs), + "dedup_key": f"subtunnel:{domain}"}, + related_packets=sorted(set(packets_per_domain.get(domain, [])))[:8], + )) + + # TXT and NULL records can carry the large payloads used by DNS tunnels. + if txt_like >= _TXT_TUNNEL_MIN and total and (txt_like / total) >= _TXT_TUNNEL_RATIO: + findings.append(Finding( + title="Unusually many TXT or NULL DNS queries", + category=Category.DNS_ANOMALY, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + protocol="dns", port=53, + risk_description=( + "TXT and NULL records can carry large payloads and are commonly abused " + "for DNS tunneling or data exfiltration." + ), + recommendation=( + "Review the requesting source and destination domain, then enforce DNS " + "tunneling detection and egress controls." + ), + evidence={"txt_null_queries": txt_like, "total_queries": total, + "ratio": round(txt_like / total, 2), "dedup_key": "txt_tunnel"}, + related_packets=sorted(set(txt_packets))[:8], + )) + + if total >= 20 and (nx / total) >= _NXDOMAIN_RATIO: + findings.append(Finding( + title="High NXDOMAIN rate", + category=Category.DNS_ANOMALY, + severity=Severity.MEDIUM, confidence=Confidence.MEDIUM, + protocol="dns", port=53, + risk_description=( + "Repeated NXDOMAIN responses may indicate malware testing generated " + "command-and-control domains." + ), + recommendation="Review the requesting source and investigate it for malware.", + evidence={"nxdomain": nx, "total_queries": total, + "ratio": round(nx / total, 2), "dedup_key": "nxdomain_rate"}, + related_packets=sorted(set(nx_packets))[:8], + )) + + return self._tag(findings) + + +def _entropy(s: str) -> float: + if not s: + return 0.0 + counts = defaultdict(int) + for ch in s: + counts[ch] += 1 + n = len(s) + return -sum((c / n) * math.log2(c / n) for c in counts.values()) diff --git a/django_project/analyzer/detectors/exfiltration.py b/django_project/analyzer/detectors/exfiltration.py new file mode 100644 index 0000000..0996a3c --- /dev/null +++ b/django_project/analyzer/detectors/exfiltration.py @@ -0,0 +1,125 @@ +"""Detect potential data exfiltration.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +# Conservative heuristic thresholds. +_LARGE_UPLOAD_BYTES = 5 * 1024 * 1024 +_AGG_UPLOAD_BYTES = 20 * 1024 * 1024 +_MANY_SMALL_TRANSFERS = 50 +_DNS_TXT_VOLUME = 100 + + +@register +class ExfiltrationDetector(BaseDetector): + name = "ExfiltrationDetector" + description = "Detects unusual outbound volumes and transfer patterns." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + per_dest_bytes: dict[str, int] = defaultdict(int) + per_dest_count: dict[str, int] = defaultdict(int) + first_seen: dict[str, float] = {} + last_seen: dict[str, float] = {} + src_for_dest: dict[str, str] = {} + packets_for_dest: dict[str, list] = defaultdict(list) + streams_for_dest: dict[str, list] = defaultdict(list) + + for conv in ctx.conversations: + # Evaluate outbound traffic from a private source to an external target. + if not (conv.src_ip and ctx.is_external(conv.dst_ip) + and not ctx.is_external(conv.src_ip)): + continue + up = conv.bytes_src_to_dst or conv.bytes + per_dest_bytes[conv.dst_ip] += up + per_dest_count[conv.dst_ip] += 1 + src_for_dest[conv.dst_ip] = conv.src_ip + if conv.packet_numbers: + packets_for_dest[conv.dst_ip].append(conv.packet_numbers[0]) + if conv.stream_id is not None: + streams_for_dest[conv.dst_ip].append(conv.stream_id) + if conv.first_seen is not None: + first_seen[conv.dst_ip] = min(first_seen.get(conv.dst_ip, conv.first_seen), conv.first_seen) + if conv.last_seen is not None: + last_seen[conv.dst_ip] = max(last_seen.get(conv.dst_ip, conv.last_seen), conv.last_seen) + + if up >= _LARGE_UPLOAD_BYTES: + findings.append(Finding( + title="Large outbound transfer to an external target", + category=Category.EXFILTRATION, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + source_ip=conv.src_ip, destination_ip=conv.dst_ip, + protocol=conv.protocol, port=conv.dst_port, + timestamp_first_seen=conv.first_seen, timestamp_last_seen=conv.last_seen, + risk_description=( + "A large amount of data left the device for an external target, " + "which may indicate data loss." + ), + recommendation="Validate the destination, content, and authorization.", + evidence={"uploaded_bytes": up, + "dedup_key": f"bigup:{conv.src_ip}:{conv.dst_ip}:{conv.dst_port}"}, + related_packets=list(conv.packet_numbers), + related_streams=[s for s in [conv.stream_id] if s is not None], + )) + + for dest, total in per_dest_bytes.items(): + if total >= _AGG_UPLOAD_BYTES: + findings.append(Finding( + title="High aggregate upload volume to an external target", + category=Category.EXFILTRATION, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + source_ip=src_for_dest.get(dest), destination_ip=dest, + timestamp_first_seen=first_seen.get(dest), + timestamp_last_seen=last_seen.get(dest), + risk_description=( + "A high total volume was sent to one external destination across " + "multiple connections, which may indicate gradual exfiltration." + ), + recommendation="Review the destination and enforce egress filtering or DLP.", + evidence={"uploaded_bytes_total": total, + "dedup_key": f"aggup:{dest}"}, + related_packets=sorted(set(packets_for_dest.get(dest, []))), + related_streams=sorted(set(streams_for_dest.get(dest, []))), + )) + elif per_dest_count[dest] >= _MANY_SMALL_TRANSFERS: + findings.append(Finding( + title="Many small outbound transfers to one target", + category=Category.EXFILTRATION, + severity=Severity.MEDIUM, confidence=Confidence.LOW, + source_ip=src_for_dest.get(dest), destination_ip=dest, + risk_description=( + "Many small outbound connections to one target may indicate " + "segmented exfiltration or beaconing." + ), + recommendation="Investigate the connection pattern and destination.", + evidence={"transfer_count": per_dest_count[dest], + "dedup_key": f"manyup:{dest}"}, + related_packets=sorted(set(packets_for_dest.get(dest, []))), + related_streams=sorted(set(streams_for_dest.get(dest, []))), + )) + + # DNS volume is a weak signal; the DNS detector provides deeper evidence. + if len(ctx.dns_queries) >= _DNS_TXT_VOLUME: + findings.append(Finding( + title="Very high DNS query volume", + category=Category.EXFILTRATION, + severity=Severity.MEDIUM, confidence=Confidence.LOW, + protocol="dns", + risk_description=( + "Unusually high DNS volume may indicate DNS tunneling or exfiltration." + ), + recommendation="Review DNS logs and correlated DNS anomaly findings.", + evidence={"dns_query_count": len(ctx.dns_queries), "dedup_key": "dns_volume"}, + related_packets=sorted( + {q.packet_number for q in ctx.dns_queries if q.packet_number} + )[:8], + )) + + return self._tag(findings) diff --git a/django_project/analyzer/detectors/insecure_protocols.py b/django_project/analyzer/detectors/insecure_protocols.py new file mode 100644 index 0000000..3e8a550 --- /dev/null +++ b/django_project/analyzer/detectors/insecure_protocols.py @@ -0,0 +1,107 @@ +"""Detect insecure or unexpected legacy protocols.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +# Port to protocol name, severity, risk, and recommendation. +_RISKY_PORTS = { + 21: ("FTP", Severity.HIGH, "FTP transmits data and credentials in clear text.", + "Replace FTP with FTPS or SFTP."), + 23: ("Telnet", Severity.CRITICAL, "Telnet transmits all content in clear text.", + "Disable Telnet and use SSH."), + 69: ("TFTP", Severity.HIGH, "TFTP has no authentication or encryption.", + "Avoid TFTP or restrict it to an isolated network."), + 80: ("HTTP", Severity.MEDIUM, "HTTP content is unencrypted and observable.", + "Require HTTPS."), + 110: ("POP3", Severity.HIGH, "POP3 without TLS exposes messages and passwords.", + "Use POP3S or STARTTLS."), + 143: ("IMAP", Severity.HIGH, "IMAP without TLS exposes messages and passwords.", + "Use IMAPS or STARTTLS."), + 161: ("SNMP", Severity.MEDIUM, "SNMP v1/v2c uses clear-text community strings.", + "Use SNMPv3 with authentication and privacy."), + 389: ("LDAP", Severity.HIGH, "LDAP without TLS exposes directory credentials.", + "Use LDAPS or StartTLS."), + 512: ("rexec", Severity.HIGH, "Remote shell services are insecure and unencrypted.", + "Disable the service and use SSH."), + 513: ("rlogin", Severity.HIGH, "Remote login services are insecure and unencrypted.", + "Disable the service and use SSH."), + 1433: ("MSSQL", Severity.MEDIUM, "The database service may be exposed without TLS.", + "Require TLS and restrict access."), + 3306: ("MySQL", Severity.MEDIUM, "The database service may be exposed without TLS.", + "Require TLS and restrict access."), + 6379: ("Redis", Severity.HIGH, "Redis may be exposed without authentication or encryption.", + "Enable authentication and restrict network exposure."), + 11211: ("Memcached", Severity.HIGH, "Memcached can be abused for amplification.", + "Bind locally and restrict access."), + 27017: ("MongoDB", Severity.HIGH, "MongoDB without authentication or TLS exposes data.", + "Enable authentication and TLS, then restrict access."), +} + + +@register +class InsecureProtocolDetector(BaseDetector): + name = "InsecureProtocolDetector" + description = "Detects clear-text and legacy protocols on known service ports." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + # Aggregate observed use of known risky service ports. + used: dict[int, dict] = defaultdict(lambda: {"src": None, "dst": None, "proto": None, + "first": None, "last": None, "packets": 0, + "pkts": [], "streams": set()}) + for conv in ctx.conversations: + for port in (conv.dst_port, conv.src_port): + if port in _RISKY_PORTS: + e = used[port] + e["src"] = conv.src_ip + e["dst"] = conv.dst_ip + e["proto"] = conv.protocol + e["packets"] += conv.packets + if conv.packet_numbers: + e["pkts"].extend(conv.packet_numbers[:4]) + if conv.stream_id is not None: + e["streams"].add(conv.stream_id) + if conv.first_seen is not None: + e["first"] = conv.first_seen if e["first"] is None else min(e["first"], conv.first_seen) + if conv.last_seen is not None: + e["last"] = conv.last_seen if e["last"] is None else max(e["last"], conv.last_seen) + + for port, e in used.items(): + name, sev, risk, rec = _RISKY_PORTS[port] + findings.append(Finding( + title=f"Insecure protocol: {name} (port {port})", + category=Category.INSECURE_PROTOCOL, + severity=sev, confidence=Confidence.HIGH, + source_ip=e["src"], destination_ip=e["dst"], + protocol=name.lower(), port=port, + timestamp_first_seen=e["first"], timestamp_last_seen=e["last"], + risk_description=risk, + recommendation=rec, + evidence={"port": port, "packets": e["packets"], + "dedup_key": f"insecure:{port}:{e['dst']}"}, + related_packets=sorted(set(e["pkts"]))[:8], + related_streams=sorted(e["streams"])[:8], + )) + + smb1 = ctx.protocol_count("smb") + if smb1 and not ctx.protocol_count("smb2", "smb3"): + findings.append(Finding( + title="SMBv1 in use", + category=Category.INSECURE_PROTOCOL, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + protocol="smb", + risk_description=( + "SMBv1 is obsolete and vulnerable to well-known attacks such as EternalBlue." + ), + recommendation="Disable SMBv1 and require SMBv2 or SMBv3.", + evidence={"smb1_packets": smb1, "dedup_key": "smbv1"}, + )) + + return self._tag(findings) diff --git a/django_project/analyzer/detectors/malware_beaconing.py b/django_project/analyzer/detectors/malware_beaconing.py new file mode 100644 index 0000000..c7597a0 --- /dev/null +++ b/django_project/analyzer/detectors/malware_beaconing.py @@ -0,0 +1,191 @@ +"""Detect malware, command-and-control, and beaconing behavior.""" + +from __future__ import annotations + +import statistics +from collections import defaultdict +from typing import Iterable, Optional + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +# Scanner and attack-tool user agents provide a strong signal. +_SCANNER_UA = ( + "nikto", "masscan", "sqlmap", "nmap", "hydra", "zgrab", "nuclei", + "dirbuster", "gobuster", "wpscan", "acunetix", "nessus", +) +# Scripting clients are a weak signal because IoT automation may use them legitimately. +_SCRIPTING_UA = ( + "curl", "wget", "python-requests", "powershell", "libwww", +) +# Common mobile and embedded clients are intentionally excluded to limit false positives. + +_MIN_BEACONS = 5 +_MAX_CV = 0.25 + + +@register +class MalwareBeaconingDetector(BaseDetector): + name = "MalwareBeaconingDetector" + description = "Detects periodic external connections and suspicious user agents." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + findings.extend(self._detect_beaconing(ctx)) + findings.extend(self._detect_suspicious_ua(ctx)) + + return self._tag(findings) + + def _detect_beaconing(self, ctx: CaptureContext) -> list[Finding]: + """Detect periodic traffic in long-lived flows and repeated short flows.""" + findings: list[Finding] = [] + reported: set[tuple] = set() + + # First evaluate multiple timestamps captured within a single flow. + for conv in ctx.conversations: + ext_ip = _external_of(ctx, conv.src_ip, conv.dst_ip) + if not ext_ip: + continue + ts = sorted(t for t in conv.timestamps if t is not None) + if len(ts) < _MIN_BEACONS: + continue + internal = conv.dst_ip if ext_ip == conv.src_ip else conv.src_ip + port = conv.src_port if ext_ip == conv.src_ip else conv.dst_port + f = self._beacon_finding( + ts, src=internal, dst=ext_ip, protocol=conv.protocol, port=port, + streams=[conv.stream_id] if conv.stream_id is not None else [], + packets=list(conv.packet_numbers), + ) + if f: + reported.add((internal, ext_ip, port)) + findings.append(f) + + # Then aggregate repeated short-lived connections to the same target. + events: dict[tuple, list[float]] = defaultdict(list) + meta: dict[tuple, dict] = {} + for conv in ctx.conversations: + if ctx.is_external(conv.dst_ip) and not ctx.is_external(conv.src_ip): + internal, external, port = conv.src_ip, conv.dst_ip, conv.dst_port + elif ctx.is_external(conv.src_ip) and not ctx.is_external(conv.dst_ip): + internal, external, port = conv.dst_ip, conv.src_ip, conv.src_port + else: + continue + if not (internal and external) or conv.first_seen is None: + continue + key = (internal, external, port) + events[key].append(conv.first_seen) + m = meta.setdefault(key, {"protocol": conv.protocol, "streams": set(), "packets": []}) + if conv.stream_id is not None: + m["streams"].add(conv.stream_id) + # One representative packet per connection keeps evidence compact. + if conv.packet_numbers: + m["packets"].append(conv.packet_numbers[0]) + + for key, first_seens in events.items(): + if key in reported or len(first_seens) < _MIN_BEACONS: + continue + internal, external, port = key + f = self._beacon_finding( + sorted(first_seens), src=internal, dst=external, + protocol=meta[key]["protocol"], port=port, + streams=sorted(meta[key]["streams"]), + packets=meta[key]["packets"], + connection_based=True, + ) + if f: + findings.append(f) + + return findings + + def _beacon_finding(self, ts: list[float], *, src, dst, protocol, port, + streams, packets=None, connection_based: bool = False) -> Optional[Finding]: + intervals = [b - a for a, b in zip(ts, ts[1:]) if (b - a) > 0] + if len(intervals) < _MIN_BEACONS - 1: + return None + mean = statistics.mean(intervals) + if mean <= 0: + return None + cv = statistics.pstdev(intervals) / mean + if cv > _MAX_CV: + return None + conf = Confidence.MEDIUM if cv > 0.1 else Confidence.HIGH + evidence = { + "beacon_count": len(ts), + "interval_seconds_mean": round(mean, 2), + "interval_cv": round(cv, 3), + "signal": "distinct_connections" if connection_based else "single_flow", + "dedup_key": f"beacon:{src}:{dst}:{port}", + } + return Finding( + title="Periodic beaconing to an external target", + category=Category.MALWARE_BEACONING, + severity=Severity.HIGH, confidence=conf, + source_ip=src, destination_ip=dst, + protocol=protocol, port=port, + timestamp_first_seen=ts[0], timestamp_last_seen=ts[-1], + risk_description=( + "The device contacts an external target at highly regular intervals, " + "which is characteristic of command-and-control beaconing." + ), + recommendation=( + "Check the target against threat intelligence, isolate the device, and " + "investigate it for compromise." + ), + evidence=evidence, + related_packets=sorted({p for p in (packets or []) if p is not None}), + related_streams=[s for s in streams if s is not None], + ) + + def _detect_suspicious_ua(self, ctx: CaptureContext) -> list[Finding]: + findings: list[Finding] = [] + seen_ua: set[str] = set() + for req in ctx.http_requests: + ua = (req.user_agent or "").lower() + if not ua: + continue + + marker = next((m for m in _SCANNER_UA if m in ua), None) + if marker: + tier, sev, conf = "scanner", Severity.HIGH, Confidence.MEDIUM + risk = ( + "The user agent belongs to a known scanning or attack tool and may " + "indicate active reconnaissance against the device." + ) + else: + marker = next((m for m in _SCRIPTING_UA if m in ua), None) + if not marker: + continue + tier, sev, conf = "scripting", Severity.LOW, Confidence.LOW + risk = ( + "An automated command-line client was used. This may be legitimate " + "automation, but it can also indicate scripts or malware." + ) + + if marker in seen_ua: + continue + seen_ua.add(marker) + findings.append(Finding( + title=f"Suspicious user agent: {marker}", + category=Category.MALWARE_BEACONING, + severity=sev, confidence=conf, + source_ip=req.src_ip, destination_ip=req.dst_ip, + protocol="http", port=req.dst_port, + timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp, + risk_description=risk, + recommendation="Verify that the source and client are authorized.", + evidence={"user_agent": req.user_agent, "host": req.host, "tier": tier, + "dedup_key": f"ua:{marker}:{req.src_ip}"}, + related_packets=[p for p in [req.packet_number] if p], + )) + return findings + + +def _external_of(ctx: CaptureContext, src_ip, dst_ip) -> Optional[str]: + """Return the external address in a pair, if present.""" + if ctx.is_external(dst_ip): + return dst_ip + if ctx.is_external(src_ip): + return src_ip + return None diff --git a/django_project/analyzer/detectors/mitm.py b/django_project/analyzer/detectors/mitm.py new file mode 100644 index 0000000..c1b02c7 --- /dev/null +++ b/django_project/analyzer/detectors/mitm.py @@ -0,0 +1,78 @@ +"""Detect local network manipulation and man-in-the-middle indicators.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + + +@register +class MitmDetector(BaseDetector): + name = "MitmDetector" + description = "Detects ARP spoofing, duplicate IP mappings, and ARP floods." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + # Map each IP address to MAC addresses observed in ARP responses. + ip_to_macs: dict[str, set[str]] = defaultdict(set) + first_seen: dict[str, float] = {} + last_seen: dict[str, float] = {} + packets_for_ip: dict[str, list[int]] = defaultdict(list) + + for ev in ctx.arp_events: + if ev.sender_ip and ev.sender_mac: + ip_to_macs[ev.sender_ip].add(ev.sender_mac) + if ev.timestamp is not None: + first_seen[ev.sender_ip] = min(first_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp) + last_seen[ev.sender_ip] = max(last_seen.get(ev.sender_ip, ev.timestamp), ev.timestamp) + if ev.packet_number: + packets_for_ip[ev.sender_ip].append(ev.packet_number) + + for ip, host in ctx.hosts.items(): + if host.macs: + ip_to_macs[ip].update(host.macs) + + for ip, macs in ip_to_macs.items(): + if len(macs) > 1: + findings.append(Finding( + title="Multiple MAC addresses associated with one IP", + category=Category.MITM, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + source_ip=ip, + protocol="arp", + timestamp_first_seen=first_seen.get(ip), + timestamp_last_seen=last_seen.get(ip), + risk_description=( + "One IP address was advertised by multiple MAC addresses, a common " + "indicator of ARP spoofing or traffic interception." + ), + recommendation=( + "Review ARP tables, enable Dynamic ARP Inspection and port security, " + "and identify the conflicting host." + ), + evidence={"ip": ip, "macs": sorted(macs), "dedup_key": f"arpdup:{ip}"}, + related_packets=sorted(packets_for_ip.get(ip, []))[:10], + )) + + # An ARP reply flood can accompany active cache poisoning. + grat = [e for e in ctx.arp_events if e.opcode == 2] + if len(grat) >= 50: + findings.append(Finding( + title="Unusually high number of ARP replies", + category=Category.MITM, + severity=Severity.MEDIUM, confidence=Confidence.LOW, + protocol="arp", + risk_description=( + "A large number of ARP replies may indicate active ARP cache poisoning." + ), + recommendation="Inspect the reply source and investigate possible interception.", + evidence={"arp_reply_count": len(grat), "dedup_key": "arp_reply_flood"}, + related_packets=sorted({e.packet_number for e in grat if e.packet_number})[:8], + )) + + return self._tag(findings) diff --git a/django_project/analyzer/detectors/password_hashes.py b/django_project/analyzer/detectors/password_hashes.py new file mode 100644 index 0000000..1541f39 --- /dev/null +++ b/django_project/analyzer/detectors/password_hashes.py @@ -0,0 +1,112 @@ +"""Detect password hashes, challenge-response material, and Kerberos tickets.""" + +from __future__ import annotations + +import re +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +_DIGEST_RE = re.compile(r"\bDigest\b", re.IGNORECASE) + + +@register +class PasswordHashDetector(BaseDetector): + name = "PasswordHashDetector" + description = ( + "Detects NTLM, Kerberos, HTTP Digest, and wireless handshake material." + ) + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + for am in ctx.auth_material: + kind = am.kind.lower() + if kind.startswith("kerberos"): + sev, conf = Severity.HIGH, Confidence.MEDIUM + title = "Kerberos authentication material in capture" + risk = ( + "Kerberos AS-REP or TGS material may enable offline password attacks " + "when weak credentials are used." + ) + rec = "Enforce pre-authentication, strong passwords, SPN hardening, and AES." + else: + sev, conf = Severity.HIGH, Confidence.MEDIUM + title = "NTLM authentication material in capture" + risk = ( + "NTLM or NetNTLM material may enable offline cracking or relay attacks " + "without exposing a clear-text password." + ) + rec = "Enable SMB signing and EPA, restrict NTLM, and prefer Kerberos." + + evidence = { + "kind": am.kind, "account": am.account, "domain": am.domain, + "dedup_key": f"auth:{am.kind}:{am.src_ip}:{am.dst_ip}:{am.account}", + } + # Fully reconstructed hashcat material raises confidence. + if am.hashcat: + evidence["hashcat"] = am.hashcat + if am.hashcat_mode is not None: + evidence["hashcat_mode"] = am.hashcat_mode + if am.hash_format: + evidence["format"] = am.hash_format + conf = Confidence.HIGH + title += " (crackable)" + rec = ( + "Rotate the affected password immediately. " + rec + + " The captured material is available in an offline-crackable format." + ) + if am.etype: + evidence["etype"] = am.etype + + findings.append(Finding( + title=title, + category=Category.PASSWORD_HASHES, + severity=sev, confidence=conf, + source_ip=am.src_ip, destination_ip=am.dst_ip, protocol=am.protocol, + timestamp_first_seen=am.timestamp, timestamp_last_seen=am.timestamp, + risk_description=risk, recommendation=rec, + evidence=evidence, + related_packets=[p for p in [am.packet_number] if p], + )) + + for req in ctx.http_requests: + if req.authorization and _DIGEST_RE.search(req.authorization): + findings.append(Finding( + title="HTTP Digest Authentication (Challenge-Response)", + category=Category.PASSWORD_HASHES, + severity=Severity.MEDIUM, confidence=Confidence.HIGH, + source_ip=req.src_ip, destination_ip=req.dst_ip, + protocol="http", port=req.dst_port, + timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp, + risk_description=( + "HTTP Digest transmits a response hash that may allow offline cracking " + "when weak passwords are used." + ), + recommendation="Require HTTPS and enforce strong passwords.", + evidence={ + "host": req.host, "uri": req.uri, + "dedup_key": f"digest:{req.src_ip}:{req.dst_ip}:{req.uri}", + }, + related_packets=[p for p in [req.packet_number] if p], + )) + + # EAPOL frames indicate potentially crackable wireless handshakes. + eapol = ctx.protocol_count("eapol") + if eapol: + findings.append(Finding( + title="WPA/WPA2 handshake material (EAPOL) detected", + category=Category.PASSWORD_HASHES, + severity=Severity.HIGH, confidence=Confidence.MEDIUM, + protocol="eapol", + risk_description=( + "EAPOL frames from a four-way handshake may enable offline brute-force " + "attacks against the wireless password." + ), + recommendation="Use a strong WPA2/WPA3 passphrase and prefer WPA3.", + evidence={"eapol_frames": eapol, "dedup_key": "eapol_handshake"}, + )) + + return self._tag(findings) diff --git a/django_project/analyzer/detectors/plaintext_credentials.py b/django_project/analyzer/detectors/plaintext_credentials.py new file mode 100644 index 0000000..2f31551 --- /dev/null +++ b/django_project/analyzer/detectors/plaintext_credentials.py @@ -0,0 +1,132 @@ +"""Detect clear-text credentials.""" + +from __future__ import annotations + +import base64 +import re +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +# Common form fields used for credentials. +_USER_FIELDS = ("user", "username", "uname", "login", "email", "uid") +_PASS_FIELDS = ("pass", "passwd", "password", "pwd", "pw") +_FORM_RE = re.compile(r"([A-Za-z0-9_\-\.]+)=([^&\s]+)") + + +@register +class PlaintextCredentialDetector(BaseDetector): + name = "PlaintextCredentialDetector" + description = "Detects usernames and passwords transmitted without encryption." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + + for pa in ctx.plaintext_auth: + if pa.username or pa.password: + findings.append(Finding( + title=f"Clear-text credentials over {pa.protocol.upper()}", + category=Category.PLAINTEXT_CREDENTIALS, + severity=Severity.CRITICAL, + confidence=Confidence.HIGH, + source_ip=pa.src_ip, destination_ip=pa.dst_ip, + protocol=pa.protocol, port=pa.dst_port, + timestamp_first_seen=pa.timestamp, timestamp_last_seen=pa.timestamp, + risk_description=( + "A username or password was transmitted without encryption. " + "A network observer could take over the account." + ), + recommendation=( + f"Replace {pa.protocol.upper()} with an encrypted alternative " + "such as SFTP, IMAPS, POP3S, or SMTP with STARTTLS, then rotate " + "the affected password." + ), + evidence={ + "username": pa.username, + "password": _mask(pa.password), + "dedup_key": f"plain:{pa.protocol}:{pa.src_ip}:{pa.dst_ip}:{pa.username}", + }, + related_packets=[p for p in [pa.packet_number] if p], + related_streams=[s for s in [pa.stream_id] if s], + )) + + for req in ctx.http_requests: + if req.is_tls: + continue + auth = (req.authorization or "").strip() + if auth.lower().startswith("basic "): + user, pwd = _decode_basic(auth[6:]) + findings.append(Finding( + title="Clear-text HTTP Basic authentication", + category=Category.PLAINTEXT_CREDENTIALS, + severity=Severity.CRITICAL, + confidence=Confidence.HIGH, + source_ip=req.src_ip, destination_ip=req.dst_ip, + protocol="http", port=req.dst_port, + timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp, + risk_description=( + "HTTP Basic authentication sends credentials with Base64 encoding, " + "not encryption, over clear-text HTTP." + ), + recommendation="Require HTTPS and rotate the affected password.", + evidence={ + "host": req.host, "uri": req.uri, + "username": user, "password": _mask(pwd), + "dedup_key": f"basic:{req.src_ip}:{req.dst_ip}:{user}", + }, + related_packets=[p for p in [req.packet_number] if p], + )) + + # Detect likely login forms in clear-text HTTP request bodies. + if req.method and req.method.upper() == "POST" and req.body: + fields = dict(_FORM_RE.findall(req.body)) + lowered = {k.lower(): v for k, v in fields.items()} + has_user = any(k in lowered for k in _USER_FIELDS) + has_pass = any(k in lowered for k in _PASS_FIELDS) + if has_pass and (has_user or len(fields) <= 4): + user_val = next((lowered[k] for k in _USER_FIELDS if k in lowered), None) + pass_val = next((lowered[k] for k in _PASS_FIELDS if k in lowered), None) + findings.append(Finding( + title="Login form submitted over clear-text HTTP", + category=Category.PLAINTEXT_CREDENTIALS, + severity=Severity.CRITICAL, + confidence=Confidence.MEDIUM, + source_ip=req.src_ip, destination_ip=req.dst_ip, + protocol="http", port=req.dst_port, + timestamp_first_seen=req.timestamp, timestamp_last_seen=req.timestamp, + risk_description=( + "A login form was submitted in a clear-text HTTP request. " + "The credentials are visible to network observers." + ), + recommendation="Serve the form only over HTTPS and rotate the password.", + evidence={ + "host": req.host, "uri": req.uri, + "username": user_val, "password": _mask(pass_val), + "dedup_key": f"form:{req.src_ip}:{req.dst_ip}:{user_val}", + }, + related_packets=[p for p in [req.packet_number] if p], + )) + + return self._tag(findings) + + +def _decode_basic(b64: str): + try: + raw = base64.b64decode(b64.strip() + "===").decode("utf-8", "ignore") + if ":" in raw: + u, p = raw.split(":", 1) + return u, p + return raw, None + except Exception: + return None, None + + +def _mask(secret): + if not secret: + return None + s = str(secret) + if len(s) <= 2: + return "*" * len(s) + return s[0] + "*" * (len(s) - 2) + s[-1] diff --git a/django_project/analyzer/detectors/tls_weakness.py b/django_project/analyzer/detectors/tls_weakness.py new file mode 100644 index 0000000..705d49c --- /dev/null +++ b/django_project/analyzer/detectors/tls_weakness.py @@ -0,0 +1,93 @@ +"""Detect weak, obsolete, or suspicious TLS connections.""" + +from __future__ import annotations + +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +_WEAK_VERSIONS = {"SSL 2.0", "SSL 3.0", "TLS 1.0", "TLS 1.1"} +# Weak cipher markers matched against normalized suite names. +_WEAK_CIPHER_MARKERS = ("RC4", "NULL", "EXPORT", "_DES_", "DES_CBC", "3DES", "MD5", "ANON") + + +@register +class TlsWeaknessDetector(BaseDetector): + name = "TlsWeaknessDetector" + description = "Detects obsolete TLS versions, weak ciphers, and certificate anomalies." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + seen: set[str] = set() + + for s in ctx.tls_sessions: + target = s.sni or s.dst_ip + base_pkt = [p for p in [s.packet_number] if p] + + if s.version and s.version in _WEAK_VERSIONS: + key = f"tlsver:{target}:{s.version}" + if key not in seen: + seen.add(key) + findings.append(Finding( + title=f"Obsolete TLS version: {s.version}", + category=Category.TLS_WEAKNESS, + severity=Severity.MEDIUM, confidence=Confidence.HIGH, + source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port, + timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp, + risk_description=( + "An obsolete TLS or SSL version is in use and is vulnerable to " + "known protocol attacks." + ), + recommendation="Require at least TLS 1.2 and prefer TLS 1.3.", + evidence={"version": s.version, "target": target, "dedup_key": key}, + related_packets=base_pkt, + )) + + if s.cipher: + up = str(s.cipher).upper() + if any(m in up for m in _WEAK_CIPHER_MARKERS): + key = f"tlscipher:{target}:{s.cipher}" + if key not in seen: + seen.add(key) + findings.append(Finding( + title="Weak TLS cipher suite", + category=Category.TLS_WEAKNESS, + severity=Severity.MEDIUM, confidence=Confidence.MEDIUM, + source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port, + timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp, + risk_description=( + "A weak or obsolete cipher suite reduces transport protection." + ), + recommendation="Allow only modern AEAD ciphers such as AES-GCM or ChaCha20.", + evidence={"cipher": s.cipher, "target": target, "dedup_key": key}, + related_packets=base_pkt, + )) + + if s.cert_self_signed or s.cert_expired: + problem = [] + if s.cert_self_signed: + problem.append("self-signed") + if s.cert_expired: + problem.append("expired") + key = f"tlscert:{target}:{'-'.join(problem)}" + if key not in seen: + seen.add(key) + findings.append(Finding( + title=f"Suspicious TLS certificate ({', '.join(problem)})", + category=Category.TLS_WEAKNESS, + severity=Severity.MEDIUM, confidence=Confidence.MEDIUM, + source_ip=s.src_ip, destination_ip=s.dst_ip, protocol="tls", port=s.dst_port, + timestamp_first_seen=s.timestamp, timestamp_last_seen=s.timestamp, + risk_description=( + "A self-signed or expired certificate may indicate endpoint " + "misconfiguration or TLS interception." + ), + recommendation="Review the certificate chain and deploy a valid CA certificate.", + evidence={"subject_cn": s.cert_subject_cn, "issuer_cn": s.cert_issuer_cn, + "not_after": s.cert_not_after, "dedup_key": key}, + related_packets=base_pkt, + )) + + return self._tag(findings) diff --git a/django_project/analyzer/detectors/tokens_secrets.py b/django_project/analyzer/detectors/tokens_secrets.py new file mode 100644 index 0000000..f9e8fdf --- /dev/null +++ b/django_project/analyzer/detectors/tokens_secrets.py @@ -0,0 +1,93 @@ +"""Detect session cookies, bearer tokens, and API keys.""" + +from __future__ import annotations + +import re +from typing import Iterable + +from ..context import CaptureContext +from ..findings import Finding, Category, Severity, Confidence +from .base import BaseDetector, register + +# JWTs consist of three base64url segments and typically start with ``eyJ``. +_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+") +_BEARER_RE = re.compile(r"\bBearer\s+([A-Za-z0-9_\-\.=]+)", re.IGNORECASE) +# Generic API key patterns in URLs, bodies, and headers. +_APIKEY_RE = re.compile( + r"(?i)(api[_\-]?key|apikey|access[_\-]?token|client[_\-]?secret|x[_\-]api[_\-]key)" + r"[=:\"']?\s*([A-Za-z0-9_\-]{16,})" +) +_SESSION_COOKIE_RE = re.compile( + r"(?i)\b(sessionid|session|sid|phpsessid|jsessionid|asp\.net_sessionid|auth|token)=" + r"([A-Za-z0-9%_\-\.]{8,})" +) + + +@register +class TokenSecretDetector(BaseDetector): + name = "TokenSecretDetector" + description = "Detects JWTs, bearer tokens, API keys, and session cookies." + + def detect(self, ctx: CaptureContext) -> Iterable[Finding]: + findings: list[Finding] = [] + seen: set[str] = set() + + for payload in ctx.text_payloads: + text = payload.get("text") or "" + over_http = payload.get("protocol") == "http" + + for m in _JWT_RE.findall(text): + findings.append(self._mk( + payload, "Clear-text JWT (JSON Web Token)", + "A JWT was transmitted in clear text and may grant passwordless access " + "until it expires.", + "Revoke and rotate the token, shorten its lifetime, and require HTTPS.", + secret=m, conf=Confidence.HIGH, seen=seen, kind="jwt")) + + for m in _BEARER_RE.findall(text): + findings.append(self._mk( + payload, "Clear-text bearer or OAuth token", + "A bearer token was transmitted and may grant access without a password.", + "Rotate the token, shorten its lifetime, and require HTTPS.", + secret=m, conf=Confidence.HIGH, seen=seen, kind="bearer")) + + for _, m in _APIKEY_RE.findall(text): + findings.append(self._mk( + payload, "Clear-text API key or client secret", + "An API key or client secret was transmitted in clear text.", + "Rotate the secret, store it securely, and require HTTPS.", + secret=m, conf=Confidence.MEDIUM, seen=seen, kind="apikey")) + + # Report session cookies only when the transport is unencrypted. + if over_http: + for _, m in _SESSION_COOKIE_RE.findall(text): + findings.append(self._mk( + payload, "Session cookie over clear-text HTTP", + "An unencrypted session cookie may enable session hijacking.", + "Set Secure and HttpOnly cookie flags and require HTTPS.", + secret=m, conf=Confidence.MEDIUM, seen=seen, kind="cookie")) + + return self._tag([f for f in findings if f is not None]) + + def _mk(self, payload, title, risk, rec, *, secret, conf, seen, kind): + key = f"{kind}:{secret[:24]}" + if key in seen: + return None + seen.add(key) + return Finding( + title=title, + category=Category.TOKENS_SECRETS, + severity=Severity.HIGH, confidence=conf, + source_ip=payload.get("src_ip"), destination_ip=payload.get("dst_ip"), + protocol=payload.get("protocol"), + timestamp_first_seen=payload.get("timestamp"), + timestamp_last_seen=payload.get("timestamp"), + risk_description=risk, recommendation=rec, + evidence={"secret_preview": _preview(secret), "type": kind, "dedup_key": key}, + related_packets=[p for p in [payload.get("packet_number")] if p], + ) + + +def _preview(secret: str) -> str: + s = str(secret) + return s[:8] + "…" + s[-4:] if len(s) > 16 else s[:4] + "…" diff --git a/django_project/analyzer/engine.py b/django_project/analyzer/engine.py new file mode 100644 index 0000000..044109a --- /dev/null +++ b/django_project/analyzer/engine.py @@ -0,0 +1,120 @@ +"""Run all registered detectors and return a prioritized analysis result.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Optional + +from .context import CaptureContext +from .findings import Finding +from . import scoring +from . import provenance +from . import detectors as detectors_pkg + + +@dataclass +class AnalysisResult: + findings: list[Finding] = field(default_factory=list) + summary: dict = field(default_factory=dict) + meta: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "meta": self.meta, + "summary": _summary_to_dict(self.summary), + "findings": [f.to_dict() for f in self.findings], + } + + +class AnalysisEngine: + def __init__(self, detector_list=None): + # Use every registered detector unless a test supplies a subset. + self.detectors = detector_list if detector_list is not None else detectors_pkg.build_all() + + def analyze_context(self, ctx: CaptureContext) -> AnalysisResult: + started = time.time() + raw: list[Finding] = [] + errors: dict[str, str] = {} + + for det in self.detectors: + try: + raw.extend(list(det.detect(ctx))) + except Exception as exc: # One detector must not abort the full analysis. + errors[det.name] = repr(exc) + + deduped = scoring.dedupe(raw) + ordered = scoring.sort_by_priority(deduped) + # Attach packet-level provenance before the summary is generated. + capture_file = _basename(ctx.path) + for f in ordered: + _enrich_provenance(f, capture_file) + summary = scoring.summarize(ordered) + + return AnalysisResult( + findings=ordered, + summary=summary, + meta={ + "capture_path": ctx.path, + "packet_count": ctx.packet_count, + "duration_seconds": round(ctx.duration, 3), + "detectors_run": [d.name for d in self.detectors], + "detector_errors": errors, + "analysis_time_seconds": round(time.time() - started, 3), + "raw_finding_count": len(raw), + "finding_count": len(ordered), + }, + ) + + def analyze_pcap(self, path: str, packet_limit: Optional[int] = None) -> AnalysisResult: + # Keep context-only analysis usable without pyshark or tshark. + from .extract_pyshark import build_context_from_pcap + ctx = build_context_from_pcap(path, packet_limit=packet_limit) + return self.analyze_context(ctx) + + +def result_from_dict(data: dict) -> AnalysisResult: + """Reconstruct a result and recalculate its summary from stored findings.""" + findings = [Finding.from_dict(f) for f in data.get("findings", [])] + summary = scoring.summarize(findings) + meta = data.get("meta", {}) + return AnalysisResult(findings=findings, summary=summary, meta=meta) + + +def _summary_to_dict(summary: dict) -> dict: + out = dict(summary) + if "highlights" in out: + out["highlights"] = [f.to_dict() for f in out["highlights"]] + if "top" in out: + out["top"] = [f.to_dict() for f in out["top"]] + return out + + +def _basename(path) -> Optional[str]: + if not path: + return None + import os + return os.path.basename(str(path)) or str(path) + + +def _enrich_provenance(f: Finding, capture_file) -> None: + """Add capture, artifact, and packet sources without replacing detector data.""" + ev = f.evidence + if capture_file and not ev.get("capture_file"): + ev["capture_file"] = capture_file + if "artifact" not in ev: + art = provenance.derive_artifact(ev) + if art is not None: + ev["artifact"] = art + if "sources" not in ev: + ev["sources"] = provenance.build_sources( + related_packets=f.related_packets, + related_streams=f.related_streams, + src_ip=f.source_ip, dst_ip=f.destination_ip, + protocol=f.protocol, port=f.port, + timestamp_first_seen=f.timestamp_first_seen, + capture=capture_file, + ) + # Preserve the complete evidence count when the displayed source list is bounded. + if f.related_packets and "source_packet_count" not in ev: + ev["source_packet_count"] = len(f.related_packets) diff --git a/django_project/analyzer/enrich/__init__.py b/django_project/analyzer/enrich/__init__.py new file mode 100644 index 0000000..5b2ae53 --- /dev/null +++ b/django_project/analyzer/enrich/__init__.py @@ -0,0 +1,11 @@ +"""Optional, lazily loaded hash and endpoint enrichment.""" + +from .hash_crack import HashCrack, crack_hash +from .url_probe import UrlProbe, probe_url +from .engine import EnrichmentEngine + +__all__ = [ + "HashCrack", "crack_hash", + "UrlProbe", "probe_url", + "EnrichmentEngine", +] diff --git a/django_project/analyzer/enrich/engine.py b/django_project/analyzer/enrich/engine.py new file mode 100644 index 0000000..7ff347e --- /dev/null +++ b/django_project/analyzer/enrich/engine.py @@ -0,0 +1,39 @@ +"""Coordinate optional enrichment and progress callbacks.""" + +from __future__ import annotations + +from typing import Callable, Optional, Sequence + +from ..artifacts import HashArtifact, UrlArtifact +from .hash_crack import HashCrack, crack_hash +from .url_probe import UrlProbe, probe_url + +# progress(done, total, item) -> None +ProgressCb = Optional[Callable[[int, int, object], None]] + + +class EnrichmentEngine: + """Run hash and endpoint enrichment for artifact lists.""" + + def crack_hashes(self, artifacts: Sequence[HashArtifact], *, + progress: ProgressCb = None, **crack_kwargs) -> list[HashCrack]: + results: list[HashCrack] = [] + total = len(artifacts) + for i, art in enumerate(artifacts, 1): + res = crack_hash(art, **crack_kwargs) + results.append(res) + if progress: + progress(i, total, res) + return results + + def probe_urls(self, artifacts: Sequence[UrlArtifact], *, + progress: ProgressCb = None, active: bool = False, + **probe_kwargs) -> list[UrlProbe]: + results: list[UrlProbe] = [] + total = len(artifacts) + for i, art in enumerate(artifacts, 1): + res = probe_url(art, active=active, **probe_kwargs) + results.append(res) + if progress: + progress(i, total, res) + return results diff --git a/django_project/analyzer/enrich/hash_crack.py b/django_project/analyzer/enrich/hash_crack.py new file mode 100644 index 0000000..c25a33a --- /dev/null +++ b/django_project/analyzer/enrich/hash_crack.py @@ -0,0 +1,144 @@ +"""Run bounded offline hashcat attacks against supported capture artifacts.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass, field +from typing import Callable, Optional, Sequence, Union + +from ..artifacts import HashArtifact, identify_hash_mode + + +@dataclass +class HashCrack: + value: str + hashcat_mode: Optional[int] = None + hash_format: Optional[str] = None + cracked: bool = False + plaintext: Optional[str] = None + duration_seconds: Optional[float] = None + # pending | cracked | not_cracked | timeout | error | unsupported + state: str = "pending" + error: Optional[str] = None + + def to_dict(self) -> dict: + return { + "value": self.value, "hashcat_mode": self.hashcat_mode, + "hash_format": self.hash_format, "cracked": self.cracked, + "plaintext": self.plaintext, "duration_seconds": self.duration_seconds, + "state": self.state, "error": self.error, + } + + +# Keep the bundled demo list first; authorized deployments may add a system list. +DEFAULT_WORDLISTS = ( + str(Path(__file__).resolve().parents[3] / "demo_files" / "demo_wordlist.txt"), + "/usr/share/wordlists/rockyou.txt", +) + + +def build_crack_command(hashcat_path: str, mode: int, hash_file: str, + wordlists: Sequence[str], potfile: str) -> list[str]: + """Build the hashcat dictionary-attack command.""" + cmd = [hashcat_path, "-m", str(mode), "-a", "0", "-D", "1,2", + hash_file, *wordlists, "--potfile-path", potfile, "--force"] + return cmd + + +def build_show_command(hashcat_path: str, mode: int, hash_file: str, + potfile: str) -> list[str]: + """Build the hashcat command used to read cracked results.""" + return [hashcat_path, "-m", str(mode), "--show", "--potfile-path", potfile, hash_file] + + +def parse_show_output(stdout: str) -> Optional[str]: + """Extract plaintext from ``hashcat --show`` output.""" + if not stdout: + return None + for line in stdout.strip().splitlines(): + if ":" in line: + return line.rsplit(":", 1)[1] + return None + + +def crack_hash( + artifact: Union[HashArtifact, str], + *, + wordlists: Sequence[str] = DEFAULT_WORDLISTS, + hashcat_path: str = "hashcat", + timeout: int = 300, + potfile: Optional[str] = None, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, + which: Callable[[str], Optional[str]] = shutil.which, +) -> HashCrack: + """Crack one artifact and convert runtime failures into a result state.""" + if isinstance(artifact, HashArtifact): + value = artifact.value + mode = artifact.hashcat_mode + fmt = artifact.hash_format + else: + value = str(artifact) + mode = None + fmt = None + + if mode is None: + mode, fmt = identify_hash_mode(value, hint_format=fmt) + + result = HashCrack(value=value, hashcat_mode=mode, hash_format=fmt) + + if mode is None: + result.state = "unsupported" + result.error = "No supported hashcat mode was detected" + return result + + if which(hashcat_path) is None: + result.state = "error" + result.error = "hashcat is not installed or not available on PATH" + return result + + available = [w for w in wordlists if os.path.exists(w)] + if not available: + result.state = "error" + result.error = "No wordlist is available" + return result + + tmpdir = tempfile.mkdtemp(prefix="hashcrack_") + hash_file = os.path.join(tmpdir, "hash.txt") + pot = potfile or os.path.join(tmpdir, "hashcat.potfile") + try: + with open(hash_file, "w", encoding="utf-8") as fh: + fh.write(value.strip() + "\n") + + start = time.time() + try: + runner(build_crack_command(hashcat_path, mode, hash_file, available, pot), + capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + result.state = "timeout" + result.duration_seconds = round(time.time() - start, 2) + result.error = f"hashcat timed out after {timeout}s" + return result + + show = runner(build_show_command(hashcat_path, mode, hash_file, pot), + capture_output=True, text=True, timeout=timeout) + result.duration_seconds = round(time.time() - start, 2) + + plaintext = parse_show_output(getattr(show, "stdout", "") or "") + if plaintext is not None: + result.cracked = True + result.plaintext = plaintext + result.state = "cracked" + else: + result.state = "not_cracked" + return result + except Exception as exc: # Enrichment failures must not stop capture analysis. + result.state = "error" + result.error = str(exc)[:300] + return result + finally: + shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/django_project/analyzer/enrich/url_probe.py b/django_project/analyzer/enrich/url_probe.py new file mode 100644 index 0000000..2f631bc --- /dev/null +++ b/django_project/analyzer/enrich/url_probe.py @@ -0,0 +1,143 @@ +"""Optionally enrich observed endpoints with DNS, port, and TLS checks. + +Passive capture evidence remains authoritative. Active checks are opt-in and +limited to a small port set because operators must have authorization to scan. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional, Sequence, Union +from urllib.parse import urlparse + +from ..artifacts import UrlArtifact + +DEFAULT_PORTS = (22, 80, 443, 8000, 8080) + + +@dataclass +class UrlProbe: + url: str + host: str + resolved_ips: list[str] = field(default_factory=list) + open_ports: list[int] = field(default_factory=list) + tls_versions: list[str] = field(default_factory=list) + cipher_suites: list[str] = field(default_factory=list) + certificate: Optional[str] = None + active: bool = False + error: Optional[str] = None + + def to_dict(self) -> dict: + return { + "url": self.url, "host": self.host, + "resolved_ips": self.resolved_ips, "open_ports": self.open_ports, + "tls_versions": self.tls_versions, "cipher_suites": self.cipher_suites, + "certificate": self.certificate, "active": self.active, "error": self.error, + } + + +def host_of(url: str) -> str: + """Reduce a URL or domain value to its hostname.""" + s = str(url).strip() + if "://" not in s: + s = "//" + s + netloc = urlparse(s).netloc or "" + host = netloc.split("@")[-1].split(":")[0] + return host or str(url).strip() + + +def probe_url( + artifact: Union[UrlArtifact, str], + *, + active: bool = False, + ports: Sequence[int] = DEFAULT_PORTS, + dns_resolver: Optional[Callable[[str], list]] = None, + port_scanner: Optional[Callable[[str, Sequence[int]], list]] = None, + tls_scanner: Optional[Callable[[str], dict]] = None, +) -> UrlProbe: + """Return passive observations and optionally perform authorized active checks.""" + if isinstance(artifact, UrlArtifact): + url = artifact.value + probe = UrlProbe(url=url, host=host_of(url)) + probe.open_ports = sorted(artifact.observed_ports) + probe.tls_versions = sorted(artifact.observed_tls) + else: + url = str(artifact) + probe = UrlProbe(url=url, host=host_of(url)) + + if not active: + return probe + + probe.active = True + host = probe.host + try: + resolver = dns_resolver or _default_dns_resolver + probe.resolved_ips = resolver(host) or [] + except Exception as exc: + probe.error = f"DNS: {exc}" + + try: + scanner = port_scanner or _default_port_scanner + found = scanner(host, ports) or [] + probe.open_ports = sorted(set(probe.open_ports) | set(found)) + except Exception as exc: + probe.error = (probe.error or "") + f" Ports: {exc}" + + try: + tls = tls_scanner or _default_tls_scanner + info = tls(host) or {} + if info.get("tls_version"): + probe.tls_versions = sorted(set(probe.tls_versions) | {info["tls_version"]}) + if info.get("cipher"): + probe.cipher_suites = sorted(set(probe.cipher_suites) | {info["cipher"]}) + probe.certificate = info.get("certificate") + except Exception as exc: + probe.error = (probe.error or "") + f" TLS: {exc}" + + return probe + + +# --------------------------------------------------------------------------- # +# Lazy defaults are imported only when active checks are requested. +# --------------------------------------------------------------------------- # + +def _default_dns_resolver(host: str) -> list[str]: + import dns.resolver # lazy + answers = dns.resolver.resolve(host, "A") + return [str(r) for r in answers] + + +def _default_port_scanner(host: str, ports: Sequence[int]) -> list[int]: + import nmap # lazy + nm = nmap.PortScanner() + port_arg = ",".join(str(p) for p in ports) + # TCP connect scanning works without raw-socket privileges; -Pn skips ICMP discovery. + nm.scan(hosts=host, arguments=f"-sT -Pn -p {port_arg} --max-retries 1 --host-timeout 20s") + open_ports: set[int] = set() + # nmap indexes results by resolved address, so iterate over every returned host. + for h in nm.all_hosts(): + try: + for port in nm[h].all_tcp(): + if nm[h]["tcp"][port]["state"] == "open": + open_ports.add(int(port)) + except Exception: + continue + return sorted(open_ports) + + +def _default_tls_scanner(host: str) -> dict: + import socket + import ssl + ctx = ssl.create_default_context() + try: + with socket.create_connection((host, 443), timeout=5) as sock: + with ctx.wrap_socket(sock, server_hostname=host) as ssock: + cert = ssock.getpeercert() + cipher = ssock.cipher() + return { + "tls_version": ssock.version(), + "cipher": cipher[0] if cipher else None, + "certificate": "Present" if cert else "Not Present", + } + except Exception: + return {} diff --git a/django_project/analyzer/extract_pyshark.py b/django_project/analyzer/extract_pyshark.py new file mode 100644 index 0000000..0d5a196 --- /dev/null +++ b/django_project/analyzer/extract_pyshark.py @@ -0,0 +1,763 @@ +"""Build a normalized capture context with tshark through pyshark. + +pyshark is loaded lazily, and individual packet handlers are isolated so one +unexpected packet cannot abort the complete analysis. +""" + +from __future__ import annotations + +import base64 +import os +from typing import Optional + +from .context import ( + CaptureContext, + Conversation, + DnsQuery, + HttpRequest, + HttpResponse, + TlsSession, + ArpEvent, + PlaintextAuth, + AuthMaterial, + FileTransfer, +) +from . import crackformats + +# Known clear-text protocols used by extraction heuristics. +CLEARTEXT_PORTS = { + 21: "ftp", + 23: "telnet", + 25: "smtp", + 80: "http", + 110: "pop3", + 143: "imap", + 389: "ldap", + 161: "snmp", + 69: "tftp", +} + + +def build_context_from_pcap(path: str, packet_limit: Optional[int] = None) -> CaptureContext: + """Read a capture and return its normalized context.""" + if not os.path.exists(path): + raise FileNotFoundError(f"Capture not found: {path}") + + try: + import pyshark # lazy import + except ImportError as exc: # pragma: no cover + raise RuntimeError( + "pyshark or tshark is unavailable. Install tshark or use the backend container." + ) from exc + + ctx = CaptureContext(path=path) + cap = pyshark.FileCapture(path, keep_packets=False) + + conv_index: dict[tuple, Conversation] = {} + # Transient state correlates NTLM and SMTP authentication across packets. + state: dict = {"ntlm_chal": {}, "smtp_auth": {}} + n = 0 + try: + for pkt in cap: + n += 1 + if packet_limit and n > packet_limit: + break + try: + _handle_packet(ctx, pkt, conv_index, state) + except Exception: + # Skip isolated malformed or unexpected packets. + continue + except Exception as exc: + if exc.__class__.__name__ == "TSharkNotFoundException": + raise RuntimeError( + "tshark was not found. Install Wireshark/tshark or use the backend container." + ) from exc + raise + finally: + try: + cap.close() + except Exception: + pass + + ctx.packet_count = n + ctx.conversations = list(conv_index.values()) + return ctx + + +# --------------------------------------------------------------------------- # +# Packet handlers +# --------------------------------------------------------------------------- # + +def _ts(pkt) -> Optional[float]: + try: + return float(pkt.sniff_timestamp) + except Exception: + return None + + +def _num(pkt) -> Optional[int]: + try: + return int(pkt.number) + except Exception: + return None + + +def _handle_packet(ctx: CaptureContext, pkt, conv_index: dict, state: Optional[dict] = None) -> None: + if state is None: + state = {"ntlm_chal": {}, "smtp_auth": {}} + ts = _ts(pkt) + if ts is not None: + ctx.start_time = ts if ctx.start_time is None else min(ctx.start_time, ts) + ctx.end_time = ts if ctx.end_time is None else max(ctx.end_time, ts) + + # Count packets by the highest decoded protocol layer. + try: + top = pkt.highest_layer + ctx.protocol_stats[top] = ctx.protocol_stats.get(top, 0) + 1 + except Exception: + pass + + layers = {l.layer_name for l in pkt.layers} + + src_ip = dst_ip = None + length = 0 + try: + length = int(pkt.length) + except Exception: + pass + + if "ip" in layers: + try: + src_ip = pkt.ip.src + dst_ip = pkt.ip.dst + except Exception: + pass + elif "ipv6" in layers: + try: + src_ip = pkt.ipv6.src + dst_ip = pkt.ipv6.dst + except Exception: + pass + + # MAC-Adressen / Hosts + src_mac = dst_mac = None + if "eth" in layers: + try: + src_mac, dst_mac = pkt.eth.src, pkt.eth.dst + except Exception: + pass + + src_port = dst_port = None + transport = None + if "tcp" in layers: + transport = "tcp" + try: + src_port = int(pkt.tcp.srcport) + dst_port = int(pkt.tcp.dstport) + except Exception: + pass + elif "udp" in layers: + transport = "udp" + try: + src_port = int(pkt.udp.srcport) + dst_port = int(pkt.udp.dstport) + except Exception: + pass + + if src_ip: + h = ctx.host(src_ip) + h.bytes_sent += length + if src_mac: + h.macs.add(src_mac) + if dst_port: + h.ports_contacted.add(dst_port) + if dst_ip: + h = ctx.host(dst_ip) + h.bytes_received += length + if dst_port: + h.ports_offered.add(dst_port) + + # Conversation aggregieren + if src_ip and dst_ip and transport: + key = tuple(sorted([(src_ip, src_port), (dst_ip, dst_port)]) + [transport]) + conv = conv_index.get(key) + if conv is None: + conv = Conversation( + src_ip=src_ip, dst_ip=dst_ip, src_port=src_port, dst_port=dst_port, + protocol=transport, first_seen=ts, last_seen=ts, + ) + try: + conv.stream_id = int(pkt.tcp.stream) if transport == "tcp" else None + except Exception: + conv.stream_id = None + conv_index[key] = conv + conv.packets += 1 + conv.bytes += length + pnum = _num(pkt) + if pnum is not None and len(conv.packet_numbers) < 20: + conv.packet_numbers.append(pnum) + if ts is not None: + conv.timestamps.append(ts) + conv.last_seen = ts if conv.last_seen is None else max(conv.last_seen, ts) + conv.first_seen = ts if conv.first_seen is None else min(conv.first_seen, ts) + if src_ip == conv.src_ip: + conv.bytes_src_to_dst += length + else: + conv.bytes_dst_to_src += length + + # TCP stream IDs support cross-packet authentication correlation. + stream_id = None + if transport == "tcp": + try: + stream_id = int(pkt.tcp.stream) + except Exception: + stream_id = None + + # Protocol-specific extraction. + if "arp" in layers: + _handle_arp(ctx, pkt, ts) + if "dns" in layers: + _handle_dns(ctx, pkt, src_ip, dst_ip, ts) + if "http" in layers: + _handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts) + if "tls" in layers or "ssl" in layers: + _handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts) + if "ftp" in layers: + _handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts) + if "telnet" in layers: + _handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts) + if "pop" in layers: + _handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts) + if "imap" in layers: + _handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts) + if "smtp" in layers: + _handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state) + if "ntlmssp" in layers: + _handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state) + if "kerberos" in layers: + _handle_kerberos(ctx, pkt, src_ip, dst_ip, ts) + + # Generic payload extraction finds artifacts that protocol fields may omit. + _handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts) + + +def _handle_arp(ctx, pkt, ts): + try: + a = pkt.arp + ctx.arp_events.append(ArpEvent( + sender_ip=getattr(a, "src_proto_ipv4", None), + sender_mac=getattr(a, "src_hw_mac", None), + target_ip=getattr(a, "dst_proto_ipv4", None), + opcode=int(getattr(a, "opcode", 0) or 0), + timestamp=ts, + packet_number=_num(pkt), + )) + except Exception: + pass + + +def _handle_dns(ctx, pkt, src_ip, dst_ip, ts): + try: + d = pkt.dns + # Ignore DNS records without a query name. + qname = getattr(d, "qry_name", None) + if not qname: + return + rcode = getattr(d, "flags_rcode", None) + rcode_map = {"0": "NOERROR", "3": "NXDOMAIN"} + response_ips = [] + for attr in ("a", "aaaa"): + response_ips.extend(_all_field_values(d, attr)) + ctx.dns_queries.append(DnsQuery( + query_name=qname, + qtype=_dns_qtype_name(getattr(d, "qry_type", None)), + response_ips=response_ips, + rcode=rcode_map.get(str(rcode), rcode), + timestamp=ts, + src_ip=src_ip, + dst_ip=dst_ip, + packet_number=_num(pkt), + )) + except Exception: + pass + + +def _handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts): + try: + h = pkt.http + if hasattr(h, "request_method") or hasattr(h, "request_uri"): + req = HttpRequest( + method=getattr(h, "request_method", None), + host=getattr(h, "host", None), + uri=getattr(h, "request_uri", None), + user_agent=getattr(h, "user_agent", None), + authorization=getattr(h, "authorization", None), + cookies=getattr(h, "cookie", None), + body=getattr(h, "file_data", None), + src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port, timestamp=ts, + packet_number=_num(pkt), + ) + ctx.http_requests.append(req) + # Preserve visible HTTP text for artifact detection. + for piece in (req.uri, req.cookies, req.authorization, req.body): + if piece: + ctx.text_payloads.append({ + "text": str(piece), "protocol": "http", + "src_ip": src_ip, "dst_ip": dst_ip, + "packet_number": _num(pkt), "timestamp": ts, + }) + if hasattr(h, "response_code"): + ct = getattr(h, "content_type", None) + ctx.http_responses.append(HttpResponse( + status_code=_safe_int(getattr(h, "response_code", None)), + content_type=ct, + server=getattr(h, "server", None), + src_ip=src_ip, dst_ip=dst_ip, timestamp=ts, packet_number=_num(pkt), + )) + # Record only responses that look like file downloads. + disposition = getattr(h, "content_disposition", None) + filename = _filename_from_disposition(disposition) + if filename or _is_download_content_type(ct): + ext = os.path.splitext(filename or "")[1].lower() or None + ctx.file_transfers.append(FileTransfer( + filename=filename, extension=ext, content_type=ct, + protocol="http", src_ip=src_ip, dst_ip=dst_ip, + direction="download", timestamp=ts, packet_number=_num(pkt), + )) + except Exception: + pass + + +def _handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts): + try: + layer = getattr(pkt, "tls", None) or getattr(pkt, "ssl", None) + if layer is None: + return + + # TLS 1.3 reports its real version in the supported-versions extension. + version = ( + getattr(layer, "handshake_extensions_supported_version", None) + or getattr(layer, "handshake_version", None) + or getattr(layer, "record_version", None) + ) + + subject_cn = _first( + getattr(layer, "x509sat_printablestring", None), + getattr(layer, "x509ce_dnsname", None), + ) + issuer_cn = getattr(layer, "x509sat_printablestring_1", None) + not_after = ( + getattr(layer, "x509af_utctime", None) + or getattr(layer, "x509af_notafter", None) + or getattr(layer, "x509af_utctime_1", None) + ) + + ctx.tls_sessions.append(TlsSession( + src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port or 443, + version=_tls_version_name(version), + cipher=getattr(layer, "handshake_ciphersuite", None), + sni=getattr(layer, "handshake_extensions_server_name", None), + cert_subject_cn=subject_cn, + cert_issuer_cn=issuer_cn, + cert_not_after=not_after, + cert_self_signed=_cert_is_self_signed(subject_cn, issuer_cn), + cert_expired=_cert_is_expired(not_after, ctx.end_time or ts), + timestamp=ts, packet_number=_num(pkt), + )) + except Exception: + pass + + +def _handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts): + try: + f = pkt.ftp + cmd = getattr(f, "request_command", None) + arg = getattr(f, "request_arg", None) + if cmd and cmd.upper() in ("USER", "PASS"): + pa = _last_plaintext(ctx, "ftp", src_ip, dst_ip, dst_port) + if cmd.upper() == "USER": + pa.username = arg + else: + pa.password = arg + pa.timestamp = ts + pa.packet_number = _num(pkt) + except Exception: + pass + + +def _handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts): + try: + data = getattr(pkt.telnet, "data", None) + if data: + ctx.text_payloads.append({ + "text": str(data), "protocol": "telnet", + "src_ip": src_ip, "dst_ip": dst_ip, + "packet_number": _num(pkt), "timestamp": ts, + }) + except Exception: + pass + + +def _handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts): + """POP3 USER/PASS im Klartext.""" + try: + p = pkt.pop + cmd = (getattr(p, "request_command", None) or "").upper() + param = getattr(p, "request_parameter", None) + if cmd in ("USER", "PASS") and param: + pa = _last_plaintext(ctx, "pop3", src_ip, dst_ip, dst_port) + if cmd == "USER": + pa.username = param + else: + pa.password = param + pa.timestamp = ts + pa.packet_number = _num(pkt) + except Exception: + pass + + +def _handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts): + """Extract clear-text IMAP LOGIN credentials.""" + try: + i = pkt.imap + line = getattr(i, "request", None) or getattr(i, "request_command", None) + if not line: + return + parts = str(line).split() + # Accept both tagged and untagged IMAP LOGIN forms. + idx = next((k for k, tok in enumerate(parts) if tok.upper() == "LOGIN"), None) + if idx is not None and len(parts) >= idx + 3: + user = parts[idx + 1].strip('"') + pw = parts[idx + 2].strip('"') + pa = _last_plaintext(ctx, "imap", src_ip, dst_ip, dst_port) + pa.username, pa.password = user, pw + pa.timestamp = ts + pa.packet_number = _num(pkt) + except Exception: + pass + + +def _handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state): + """Extract Base64-encoded SMTP AUTH LOGIN or PLAIN credentials.""" + try: + s = pkt.smtp + param = getattr(s, "req_parameter", None) or getattr(s, "request_parameter", None) + cmd = (getattr(s, "req_command", None) or getattr(s, "request_command", None) or "").upper() + if not param and not cmd: + return + pending = state["smtp_auth"].setdefault(stream_id, {"mech": None, "tokens": []}) + + text = f"{cmd} {param}".strip() + upper = text.upper() + if "AUTH LOGIN" in upper: + pending["mech"] = "login" + return + if "AUTH PLAIN" in upper: + pending["mech"] = "plain" + # AUTH PLAIN may carry its token on the command line. + tail = text.split() + if len(tail) >= 3: + creds = crackformats.decode_smtp_auth_plain(tail[2]) + _emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt) + state["smtp_auth"].pop(stream_id, None) + return + + # AUTH LOGIN sends username and password in subsequent lines. + if pending["mech"] == "login" and param: + pending["tokens"].append(param) + if len(pending["tokens"]) >= 2: + user, pw = crackformats.decode_smtp_auth_login( + pending["tokens"][0], pending["tokens"][1]) + _emit_smtp(ctx, (user, pw), src_ip, dst_ip, dst_port, ts, pkt) + state["smtp_auth"].pop(stream_id, None) + elif pending["mech"] == "plain" and param: + creds = crackformats.decode_smtp_auth_plain(param) + _emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt) + state["smtp_auth"].pop(stream_id, None) + except Exception: + pass + + +def _emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt): + if not creds: + return + user, pw = creds + if not (user or pw): + return + ctx.plaintext_auth.append(PlaintextAuth( + protocol="smtp", username=user, password=pw, + src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port, + timestamp=ts, packet_number=_num(pkt), + )) + + +def _handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state): + """Correlate NTLM challenge and response packets into hashcat material.""" + try: + n = pkt.ntlmssp + msg_type = str(getattr(n, "messagetype", "") or "").lower() + + # Retain the type-2 server challenge for the matching type-3 response. + server_chal = getattr(n, "ntlmserverchallenge", None) + if server_chal and ("0x00000002" in msg_type or msg_type.endswith("2")): + state["ntlm_chal"][stream_id] = str(server_chal) + + # A type-3 message provides the account and NT response. + account = getattr(n, "auth_username", None) + domain = getattr(n, "auth_domain", None) + nt_resp = (getattr(n, "ntlmv2_response", None) + or getattr(n, "auth_ntresponse", None)) + + if account or nt_resp: + chal = state["ntlm_chal"].get(stream_id) + am = AuthMaterial( + kind="ntlmssp", account=account, domain=domain, + src_ip=src_ip, dst_ip=dst_ip, protocol="ntlm", + timestamp=ts, packet_number=_num(pkt), + server_challenge=chal, nt_response=str(nt_resp) if nt_resp else None, + ) + built = crackformats.build_netntlmv2(account, domain, chal, nt_resp) + if built: + am.kind = "netntlmv2" + am.hashcat = built["hashcat"] + am.hashcat_mode = built["hashcat_mode"] + am.hash_format = built["format"] + ctx.auth_material.append(am) + except Exception: + pass + + +# Bound retained payload text for large captures. +_MAX_TEXT_PAYLOADS = 20000 + + +def _handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts): + """Retain bounded TCP or UDP text for generic artifact detection.""" + if len(ctx.text_payloads) >= _MAX_TEXT_PAYLOADS: + return + raw = None + try: + if "tcp" in layers and hasattr(pkt.tcp, "payload"): + raw = pkt.tcp.payload + elif "udp" in layers and hasattr(pkt.udp, "payload"): + raw = pkt.udp.payload + elif "data" in layers: + raw = getattr(pkt.data, "data", None) + except Exception: + raw = None + if not raw: + return + + text = _bytes_from_hexish(str(raw)) + if not text or not text.strip(): + return + try: + proto = (pkt.highest_layer or "raw").lower() + except Exception: + proto = "raw" + ctx.text_payloads.append({ + "text": text[:16384], "protocol": proto, + "src_ip": src_ip, "dst_ip": dst_ip, + "packet_number": _num(pkt), "timestamp": ts, + }) + + +def _bytes_from_hexish(raw: str) -> Optional[str]: + """Convert a hex-formatted pyshark payload to text when possible.""" + s = str(raw).strip() + compact = s.replace(":", "").replace(".", "").replace(" ", "") + if compact and len(compact) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in compact): + try: + return bytes.fromhex(compact).decode("utf-8", "ignore") + except Exception: + return None + # Some dissectors already expose decoded text. + return s + + +def _handle_kerberos(ctx, pkt, src_ip, dst_ip, ts): + """Extract Kerberos AS-REP and TGS-REP material for offline analysis.""" + try: + k = pkt.kerberos + msg_type = str(getattr(k, "msg_type", "") or "") + account = getattr(k, "cnamestring", None) or getattr(k, "cnamestring_1", None) + realm = getattr(k, "realm", None) or getattr(k, "crealm", None) + etype = getattr(k, "etype", None) + cipher = getattr(k, "cipher", None) or getattr(k, "enc_part_cipher", None) + + am = AuthMaterial( + kind=f"kerberos_{msg_type}" if msg_type else "kerberos", + account=account, domain=realm, + src_ip=src_ip, dst_ip=dst_ip, protocol="kerberos", + timestamp=ts, packet_number=_num(pkt), + etype=str(etype) if etype is not None else None, + ) + # Kerberos message types 11 and 13 are AS-REP and TGS-REP. + built = None + if msg_type == "11": + built = crackformats.build_krb5_asrep(account, realm, etype, cipher) + elif msg_type == "13": + spn = getattr(k, "snamestring", None) + built = crackformats.build_krb5_tgsrep(account, realm, spn, etype, cipher) + if built: + am.hashcat = built["hashcat"] + am.hashcat_mode = built["hashcat_mode"] + am.hash_format = built["format"] + ctx.auth_material.append(am) + except Exception: + pass + + +# --------------------------------------------------------------------------- # +# kleine Helfer +# --------------------------------------------------------------------------- # + +def _last_plaintext(ctx, protocol, src_ip, dst_ip, dst_port) -> PlaintextAuth: + """Find or create the authentication record that combines USER and PASS.""" + for pa in reversed(ctx.plaintext_auth): + if pa.protocol == protocol and pa.src_ip == src_ip and pa.dst_ip == dst_ip: + if pa.password is None or pa.username is None: + return pa + pa = PlaintextAuth(protocol=protocol, src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port) + ctx.plaintext_auth.append(pa) + return pa + + +def _safe_int(v): + try: + return int(v) + except Exception: + return None + + +def _tls_version_name(raw) -> Optional[str]: + mapping = { + "0x0300": "SSL 3.0", "0x0301": "TLS 1.0", "0x0302": "TLS 1.1", + "0x0303": "TLS 1.2", "0x0304": "TLS 1.3", + "768": "SSL 3.0", "769": "TLS 1.0", "770": "TLS 1.1", + "771": "TLS 1.2", "772": "TLS 1.3", + } + if raw is None: + return None + return mapping.get(str(raw).lower(), str(raw)) + + +def _first(*vals): + """Return the first non-empty dissector field value.""" + for v in vals: + if v: + return v + return None + + +def _all_field_values(layer, name) -> list[str]: + """Return every occurrence of a repeated pyshark field.""" + out: list[str] = [] + try: + field = getattr(layer, name, None) + if field is None: + return out + allf = getattr(field, "all_fields", None) + if allf: + for f in allf: + v = getattr(f, "show", None) or getattr(f, "showname_value", None) + if v: + out.append(str(v)) + else: + out.append(str(field)) + except Exception: + pass + return out + + +# tshark often exposes DNS query types as numeric values. +_DNS_QTYPES = { + "1": "A", "2": "NS", "5": "CNAME", "6": "SOA", "12": "PTR", "15": "MX", + "16": "TXT", "28": "AAAA", "33": "SRV", "255": "ANY", "10": "NULL", + "43": "DS", "48": "DNSKEY", "257": "CAA", "65": "HTTPS", "64": "SVCB", +} + + +def _dns_qtype_name(raw) -> Optional[str]: + if raw is None: + return None + s = str(raw).strip() + return _DNS_QTYPES.get(s, s.upper()) + + +# Content types that indicate a file rather than ordinary web content. +_DOWNLOAD_CT_MARKERS = ( + "application/octet-stream", "application/x-msdownload", "application/x-dosexec", + "application/x-executable", "application/vnd.microsoft.portable-executable", + "application/zip", "application/x-rar", "application/x-7z", "application/x-tar", + "application/gzip", "application/x-msi", "application/x-sh", + "application/java-archive", "application/x-elf", +) + + +def _is_download_content_type(ct) -> bool: + if not ct: + return False + low = str(ct).lower() + return any(m in low for m in _DOWNLOAD_CT_MARKERS) + + +def _filename_from_disposition(disposition) -> Optional[str]: + """Extract a filename from a Content-Disposition header.""" + if not disposition: + return None + import re as _re + m = _re.search(r'filename\*?=(?:"([^"]+)"|([^;]+))', str(disposition), _re.IGNORECASE) + if not m: + return None + name = (m.group(1) or m.group(2) or "").strip().strip('"') + # Remove an optional RFC 5987 character-set prefix. + if "''" in name: + name = name.split("''", 1)[1] + return name or None + + +def _cert_is_self_signed(subject_cn, issuer_cn) -> Optional[bool]: + """Return whether subject and issuer match, or None if data is incomplete.""" + if not subject_cn or not issuer_cn: + return None + return str(subject_cn).strip().lower() == str(issuer_cn).strip().lower() + + +def _cert_is_expired(not_after, now_epoch) -> Optional[bool]: + """Compare a parsed certificate expiry with the capture timestamp.""" + if not not_after or now_epoch is None: + return None + exp = _parse_cert_time(not_after) + if exp is None: + return None + try: + return float(now_epoch) > exp + except (TypeError, ValueError): + return None + + +def _parse_cert_time(raw) -> Optional[float]: + """Convert common X.509 time formats to a UTC epoch timestamp.""" + import datetime as _dt + + s = str(raw).strip() + # tshark may already provide an epoch value. + try: + return float(s) + except ValueError: + pass + fmts = ( + "%Y-%m-%d %H:%M:%S", # 2020-01-01 00:00:00 + "%b %d %H:%M:%S %Y GMT", # Jan 1 00:00:00 2020 GMT + "%y%m%d%H%M%SZ", # UTCTime 200101000000Z + "%Y%m%d%H%M%SZ", # GeneralizedTime + "%Y-%m-%dT%H:%M:%SZ", # ISO 8601 + ) + for fmt in fmts: + try: + dt = _dt.datetime.strptime(s, fmt).replace(tzinfo=_dt.timezone.utc) + return dt.timestamp() + except ValueError: + continue + return None diff --git a/django_project/analyzer/findings.py b/django_project/analyzer/findings.py new file mode 100644 index 0000000..a5d1839 --- /dev/null +++ b/django_project/analyzer/findings.py @@ -0,0 +1,166 @@ +"""Shared finding taxonomy for IoT PreTester 4.6 Demo. + +The module deliberately has no Django or third-party dependencies so the same +model works in the web backend, CLI, and unit tests. +""" + +from __future__ import annotations + +import enum +import hashlib +from dataclasses import dataclass, field, asdict +from typing import Any, Optional + + +class Severity(enum.Enum): + """Potential impact if the finding is valid.""" + + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + + @property + def rank(self) -> int: + return _SEVERITY_RANK[self] + + +class Confidence(enum.Enum): + """Confidence in the evidence extracted from the capture.""" + + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + @property + def rank(self) -> int: + return _CONFIDENCE_RANK[self] + + +class Category(enum.Enum): + """Security finding categories covered by the detector set.""" + + PLAINTEXT_CREDENTIALS = "plaintext_credentials" + PASSWORD_HASHES = "password_hashes" + TOKENS_SECRETS = "tokens_secrets" + MALWARE_BEACONING = "malware_beaconing" + EXFILTRATION = "exfiltration" + MITM = "mitm" + INSECURE_PROTOCOL = "insecure_protocol" + TLS_WEAKNESS = "tls_weakness" + DNS_ANOMALY = "dns_anomaly" + BRUTEFORCE_SCAN_FILETRANSFER = "bruteforce_scan_filetransfer" + + +_SEVERITY_RANK = { + Severity.CRITICAL: 4, + Severity.HIGH: 3, + Severity.MEDIUM: 2, + Severity.LOW: 1, + Severity.INFO: 0, +} + +_CONFIDENCE_RANK = { + Confidence.HIGH: 2, + Confidence.MEDIUM: 1, + Confidence.LOW: 0, +} + + +def _coerce(value, enum_cls): + """Accept enum members and their serialized string values.""" + if isinstance(value, enum_cls): + return value + if isinstance(value, str): + return enum_cls(value) + raise TypeError(f"{value!r} is not a valid {enum_cls.__name__}") + + +@dataclass +class Finding: + """Shared data model for detector output and packet evidence.""" + + title: str + category: Category + severity: Severity + confidence: Confidence + risk_description: str + recommendation: str + + affected_device: Optional[str] = None + source_ip: Optional[str] = None + destination_ip: Optional[str] = None + protocol: Optional[str] = None + port: Optional[int] = None + timestamp_first_seen: Optional[float] = None + timestamp_last_seen: Optional[float] = None + + # Structured evidence captured by the detector. + evidence: dict[str, Any] = field(default_factory=dict) + related_packets: list[int] = field(default_factory=list) + related_streams: list[int] = field(default_factory=list) + + # Filled by the engine with the detector class name. + detector: Optional[str] = None + finding_id: Optional[str] = None + + def __post_init__(self) -> None: + self.category = _coerce(self.category, Category) + self.severity = _coerce(self.severity, Severity) + self.confidence = _coerce(self.confidence, Confidence) + if self.finding_id is None: + self.finding_id = self.compute_id() + + def compute_id(self) -> str: + """Build a stable identifier from the finding's identity fields.""" + basis = "|".join( + str(x) + for x in ( + self.category.value, + self.title, + self.source_ip, + self.destination_ip, + self.protocol, + self.port, + _evidence_fingerprint(self.evidence), + ) + ) + return hashlib.sha1(basis.encode("utf-8")).hexdigest()[:16] + + @property + def priority(self) -> tuple[int, int]: + """Sort by severity first, then confidence.""" + return (self.severity.rank, self.confidence.rank) + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["category"] = self.category.value + data["severity"] = self.severity.value + data["confidence"] = self.confidence.value + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Finding": + """Reconstruct a finding from serialized data.""" + fields = { + "title", "category", "severity", "confidence", "risk_description", + "recommendation", "affected_device", "source_ip", "destination_ip", + "protocol", "port", "timestamp_first_seen", "timestamp_last_seen", + "evidence", "related_packets", "related_streams", "detector", "finding_id", + } + kwargs = {k: v for k, v in data.items() if k in fields} + return cls(**kwargs) + + +def _evidence_fingerprint(evidence: dict[str, Any]) -> str: + """Prefer an explicit deduplication key, then stable sorted evidence.""" + if not evidence: + return "" + if "dedup_key" in evidence: + return str(evidence["dedup_key"]) + try: + items = sorted((str(k), str(v)) for k, v in evidence.items()) + except Exception: + return str(evidence) + return ";".join(f"{k}={v}" for k, v in items) diff --git a/django_project/analyzer/provenance.py b/django_project/analyzer/provenance.py new file mode 100644 index 0000000..1ca4138 --- /dev/null +++ b/django_project/analyzer/provenance.py @@ -0,0 +1,110 @@ +"""Build packet-level provenance without coupling findings to PCAP I/O.""" + +from __future__ import annotations + +from typing import Any, Optional + +# Keep reports compact while preserving the complete packet count separately. +MAX_SOURCE_PACKETS = 8 + + +def source_ref(*, packet: Optional[int] = None, stream: Optional[int] = None, + timestamp: Optional[float] = None, src_ip: Optional[str] = None, + dst_ip: Optional[str] = None, protocol: Optional[str] = None, + port: Optional[int] = None, capture: Optional[str] = None, + note: Optional[str] = None) -> dict: + """Build a normalized source reference while omitting empty fields.""" + ref: dict[str, Any] = {} + if packet is not None: + ref["packet"] = packet + if stream is not None: + ref["stream"] = stream + if timestamp is not None: + ref["timestamp"] = timestamp + if src_ip: + ref["src_ip"] = src_ip + if dst_ip: + ref["dst_ip"] = dst_ip + if protocol: + ref["protocol"] = protocol + if port: + ref["port"] = port + if capture: + ref["capture"] = capture + if note: + ref["note"] = note + return ref + + +# Priority-ordered evidence keys used to select the primary artifact. +_ARTIFACT_KEYS = [ + ("hashcat", "Hash (hashcat)"), + ("secret_preview", "Secret/Token"), + ("query_name", "DNS query"), + ("cipher", "TLS-Cipher"), + ("version", "TLS-Version"), + ("user_agent", "User-Agent"), + ("filename", "File"), + ("uri", "URI"), + ("subject_cn", "Certificate CN"), + ("registered_domain", "Domain"), + ("macs", "MAC addresses"), +] + + +def derive_artifact(evidence: dict) -> Optional[dict]: + """Derive the most representative artifact from structured evidence.""" + if not evidence: + return None + + # Treat a username and masked password as one artifact. + user = evidence.get("username") + pw = evidence.get("password") + if user or pw: + return {"label": "Credentials", "value": f"{user or '?'} : {pw or '?'}"} + + for key, label in _ARTIFACT_KEYS: + val = evidence.get(key) + if val: + if isinstance(val, (list, tuple)): + val = ", ".join(str(x) for x in val) + return {"label": label, "value": str(val)} + return None + + +def build_sources(*, related_packets: list[int], related_streams: list[int], + src_ip: Optional[str], dst_ip: Optional[str], + protocol: Optional[str], port: Optional[int], + timestamp_first_seen: Optional[float], + capture: Optional[str], + max_packets: int = MAX_SOURCE_PACKETS) -> list[dict]: + """Build a bounded source list from packet, stream, and endpoint fields.""" + packets = list(dict.fromkeys(p for p in (related_packets or []) if p is not None)) + streams = list(dict.fromkeys(s for s in (related_streams or []) if s is not None)) + single_stream = streams[0] if len(streams) == 1 else None + + sources: list[dict] = [] + if packets: + single_ts = timestamp_first_seen if len(packets) == 1 else None + for p in packets[:max_packets]: + sources.append(source_ref( + packet=p, stream=single_stream, timestamp=single_ts, + src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, port=port, + capture=capture, + )) + elif streams: + for s in streams[:max_packets]: + sources.append(source_ref( + stream=s, src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, + port=port, capture=capture, + )) + elif src_ip or dst_ip or timestamp_first_seen is not None: + sources.append(source_ref( + src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, port=port, + timestamp=timestamp_first_seen, capture=capture, + note="aggregated evidence; no single packet is available", + )) + elif capture: + sources.append(source_ref(capture=capture, + note="aggregated evidence; no single packet is available")) + return sources diff --git a/django_project/analyzer/reporting/__init__.py b/django_project/analyzer/reporting/__init__.py new file mode 100644 index 0000000..b05a5dd --- /dev/null +++ b/django_project/analyzer/reporting/__init__.py @@ -0,0 +1,25 @@ +"""Report renderers for Markdown, plain text, and JSON.""" + +from __future__ import annotations + +from ..engine import AnalysisResult +from . import markdown as _md +from . import text as _txt +from . import json_report as _json + +FORMATS = ("md", "txt", "json") + + +def render(result: AnalysisResult, fmt: str, extra: dict | None = None) -> str: + """Render a report with optional endpoint and hash enrichment sections.""" + fmt = (fmt or "md").lower() + if fmt in ("md", "markdown"): + return _md.render(result, extra) + if fmt in ("txt", "text"): + return _txt.render(result, extra) + if fmt == "json": + return _json.render(result, extra) + raise ValueError(f"Unknown report format: {fmt!r} (allowed: {', '.join(FORMATS)})") + + +__all__ = ["render", "FORMATS"] diff --git a/django_project/analyzer/reporting/_common.py b/django_project/analyzer/reporting/_common.py new file mode 100644 index 0000000..c3f9dea --- /dev/null +++ b/django_project/analyzer/reporting/_common.py @@ -0,0 +1,107 @@ +"""Shared report-rendering helpers.""" + +from __future__ import annotations + +import datetime as _dt +from ..findings import Finding, Severity + +SEVERITY_LABEL = { + Severity.CRITICAL: "CRITICAL", + Severity.HIGH: "HIGH", + Severity.MEDIUM: "MEDIUM", + Severity.LOW: "LOW", + Severity.INFO: "INFO", +} + + +def fmt_ts(ts) -> str: + if ts is None: + return "—" + try: + return _dt.datetime.fromtimestamp(float(ts), _dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + except Exception: + return str(ts) + + +def affected_line(f: Finding) -> str: + """Format affected devices, endpoints, and ports as one compact line.""" + parts = [] + if f.affected_device: + parts.append(f"Device {f.affected_device}") + if f.source_ip: + parts.append(f"Source {f.source_ip}") + if f.destination_ip: + parts.append(f"Destination {f.destination_ip}") + if f.protocol: + proto = f.protocol + (f":{f.port}" if f.port else "") + parts.append(proto) + for k in ("host", "registered_domain", "query_name", "target"): + if f.evidence.get(k): + parts.append(str(f.evidence[k])) + break + return ", ".join(parts) if parts else "—" + + +# Evidence rendered in dedicated sections is excluded from generic details. +_EVIDENCE_SKIP = { + "dedup_key", "sources", "artifact", "capture_file", "source_packet_count", + "occurrences", +} + + +def artifact_line(f: Finding) -> str: + """Format the primary artifact as one compact line.""" + art = f.evidence.get("artifact") + if isinstance(art, dict) and art.get("value"): + label = art.get("label") + return f"{label}: {art['value']}" if label else str(art["value"]) + if art: + return str(art) + return "—" + + +def fmt_source(ref: dict) -> str: + """Format one packet-level source reference.""" + parts = [] + if ref.get("packet") is not None: + parts.append(f"Packet #{ref['packet']}") + if ref.get("stream") is not None: + parts.append(f"Stream {ref['stream']}") + if ref.get("timestamp") is not None: + parts.append(fmt_ts(ref["timestamp"])) + if ref.get("src_ip") or ref.get("dst_ip"): + parts.append(f"{ref.get('src_ip', '?')} → {ref.get('dst_ip', '?')}") + if ref.get("protocol"): + proto = ref["protocol"] + (f":{ref['port']}" if ref.get("port") else "") + parts.append(proto) + if ref.get("capture"): + parts.append(f"Capture {ref['capture']}") + if ref.get("note"): + parts.append(str(ref["note"])) + return " · ".join(parts) if parts else "—" + + +def sources_list(f: Finding) -> list[str]: + """Return formatted packet-level source references for a finding.""" + refs = f.evidence.get("sources") or [] + lines = [fmt_source(r) for r in refs] + total = f.evidence.get("source_packet_count") + if total and len(f.related_packets or []) and total > len(refs): + lines.append(f"… and {total - len(refs)} more packets") + return lines + + +def evidence_line(f: Finding) -> str: + """Format packet, stream, time, and detector-specific evidence.""" + parts = [] + if f.related_packets: + pk = ", ".join(map(str, f.related_packets[:8])) + parts.append(f"Packets: {pk}") + if f.related_streams: + parts.append(f"Streams: {', '.join(map(str, f.related_streams[:8]))}") + parts.append(f"Time: {fmt_ts(f.timestamp_first_seen)} – {fmt_ts(f.timestamp_last_seen)}") + extras = {k: v for k, v in f.evidence.items() if k not in _EVIDENCE_SKIP and v is not None} + if extras: + kv = ", ".join(f"{k}={v}" for k, v in list(extras.items())[:6]) + parts.append(kv) + return " | ".join(parts) diff --git a/django_project/analyzer/reporting/json_report.py b/django_project/analyzer/reporting/json_report.py new file mode 100644 index 0000000..c771bd2 --- /dev/null +++ b/django_project/analyzer/reporting/json_report.py @@ -0,0 +1,14 @@ +"""Machine-readable JSON report renderer.""" + +from __future__ import annotations + +import json + +from ..engine import AnalysisResult + + +def render(result: AnalysisResult, extra: dict | None = None) -> str: + data = result.to_dict() + if extra: + data["cloud_results"] = extra + return json.dumps(data, indent=2, ensure_ascii=False, default=str) diff --git a/django_project/analyzer/reporting/markdown.py b/django_project/analyzer/reporting/markdown.py new file mode 100644 index 0000000..b8c8647 --- /dev/null +++ b/django_project/analyzer/reporting/markdown.py @@ -0,0 +1,111 @@ +"""Markdown report renderer.""" + +from __future__ import annotations + +from ..engine import AnalysisResult +from ..findings import Severity +from ._common import ( + SEVERITY_LABEL, fmt_ts, affected_line, evidence_line, artifact_line, sources_list, +) + + +def render(result: AnalysisResult, extra: dict | None = None) -> str: + m = result.meta + s = result.summary + out: list[str] = [] + + out.append("# IoT PreTester 4.6 Demo — Network Capture Security Analysis") + out.append("") + out.append(f"- **Capture:** `{m.get('capture_path', '—')}`") + out.append(f"- **Packets:** {m.get('packet_count', 0)} · " + f"**Duration:** {m.get('duration_seconds', 0)} s · " + f"**Findings:** {m.get('finding_count', 0)}") + out.append("") + + bysev = s.get("by_severity", {}) + out.append("## Summary") + out.append("") + out.append("| Severity | Count |") + out.append("| ----------- | ------ |") + for sev in Severity: + out.append(f"| {SEVERITY_LABEL[sev]} | {bysev.get(sev.value, 0)} |") + out.append("") + + highlights = s.get("highlights", []) + if highlights: + out.append("## ⚠️ Prioritized Highlights") + out.append("") + out.append("Highest-impact credential, token, C2, and exfiltration findings:") + out.append("") + for f in highlights: + out.append(f"- **[{SEVERITY_LABEL[f.severity]}/{f.confidence.value}]** " + f"{f.title} — {affected_line(f)}") + out.append("") + + out.append("## Findings") + out.append("") + if not result.findings: + out.append("_No security-relevant findings detected._") + for i, f in enumerate(result.findings, 1): + out.append(f"### {i}. {f.title}") + out.append("") + out.append(f"- **Severity:** {SEVERITY_LABEL[f.severity]} " + f"· **Confidence:** {f.confidence.value} " + f"· **Category:** {f.category.value} " + f"· **Detector:** {f.detector}") + art = artifact_line(f) + if art and art != "—": + out.append(f"- **🔎 Observed artifact:** `{art}`") + srcs = sources_list(f) + if srcs: + out.append("- **📍 Sources:**") + for source in srcs: + out.append(f" - {source}") + out.append(f"- **Risk:** {f.risk_description}") + out.append(f"- **Affected:** {affected_line(f)}") + out.append(f"- **Additional evidence:** {evidence_line(f)}") + out.append(f"- **Recommendation:** {f.recommendation}") + out.append(f"- **Finding ID:** `{f.finding_id}`") + out.append("") + + # Optional URL and hash enrichment results. + if extra: + hashes = extra.get("hashes") or [] + urls = extra.get("urls") or [] + if hashes: + out.append("") + out.append(f"## Hashes ({len(hashes)})") + out.append("") + for h in hashes: + if h.get("cracked"): + state = f"**CRACKED** → `{h.get('cracked_value')}`" + if h.get("crack_time"): + state += f" ({h['crack_time']})" + elif h.get("analyzed"): + state = "not cracked" + else: + state = "analysis pending" + htype = ", ".join(h.get("hash_type") or []) + out.append(f"- `{h.get('hash_value')}`" + f"{f' [{htype}]' if htype else ''} — {state}") + if urls: + out.append("") + out.append(f"## Cloud URLs and Endpoints ({len(urls)})") + out.append("") + for u in urls: + if u.get("analyzed"): + ports = ", ".join(map(str, u.get("ports") or [])) or "—" + details = f"Ports: {ports}" + if u.get("tls_versions"): + details += f"; TLS: {', '.join(u['tls_versions'])}" + if u.get("cipher_suites"): + details += f"; Cipher: {', '.join(u['cipher_suites'])}" + else: + details = "analysis pending" + out.append(f"- `{u.get('url')}` — {details}") + + out.append("") + out.append("---") + out.append(f"_Generated by IoT PreTester 4.6 Demo · Detectors: " + f"{', '.join(m.get('detectors_run', []))}_") + return "\n".join(out) diff --git a/django_project/analyzer/reporting/text.py b/django_project/analyzer/reporting/text.py new file mode 100644 index 0000000..72e5dea --- /dev/null +++ b/django_project/analyzer/reporting/text.py @@ -0,0 +1,100 @@ +"""Plain-text report renderer.""" + +from __future__ import annotations + +from ..engine import AnalysisResult +from ..findings import Severity +from ._common import ( + SEVERITY_LABEL, affected_line, evidence_line, artifact_line, sources_list, +) + + +def render(result: AnalysisResult, extra: dict | None = None) -> str: + m = result.meta + s = result.summary + out: list[str] = [] + + line = "=" * 70 + out.append(line) + out.append("IoT PreTester 4.6 Demo - Network Capture Security Analysis") + out.append(line) + out.append(f"Capture : {m.get('capture_path', '-')}") + out.append(f"Packets : {m.get('packet_count', 0)} Duration: {m.get('duration_seconds', 0)}s") + out.append(f"Findings: {m.get('finding_count', 0)}") + out.append("") + + bysev = s.get("by_severity", {}) + out.append("SUMMARY") + out.append("-" * 70) + for sev in Severity: + out.append(f" {SEVERITY_LABEL[sev]:<8}: {bysev.get(sev.value, 0)}") + out.append("") + + highlights = s.get("highlights", []) + if highlights: + out.append("PRIORITIZED HIGHLIGHTS") + out.append("-" * 70) + for f in highlights: + out.append(f" [{SEVERITY_LABEL[f.severity]}/{f.confidence.value}] " + f"{f.title} -- {affected_line(f)}") + out.append("") + + out.append("FINDINGS") + out.append("-" * 70) + if not result.findings: + out.append(" No security-relevant findings detected.") + for i, f in enumerate(result.findings, 1): + out.append(f"[{i}] {f.title}") + out.append(f" Severity : {SEVERITY_LABEL[f.severity]} / Confidence: {f.confidence.value}") + out.append(f" Category : {f.category.value} (Detector: {f.detector})") + art = artifact_line(f) + if art and art != "—": + out.append(f" ARTIFACT : {art}") + srcs = sources_list(f) + if srcs: + out.append(" Sources :") + for source in srcs: + out.append(f" - {source}") + out.append(f" Risk : {f.risk_description}") + out.append(f" Affected : {affected_line(f)}") + out.append(f" Evidence : {evidence_line(f)}") + out.append(f" Recommendation : {f.recommendation}") + out.append(f" Finding ID : {f.finding_id}") + out.append("") + + if extra: + hashes = extra.get("hashes") or [] + urls = extra.get("urls") or [] + if hashes: + out.append(f"HASHES ({len(hashes)})") + out.append("-" * 70) + for h in hashes: + if h.get("cracked"): + state = f"CRACKED -> {h.get('cracked_value')}" + if h.get("crack_time"): + state += f" ({h['crack_time']})" + elif h.get("analyzed"): + state = "not cracked" + else: + state = "analysis pending" + htype = ", ".join(h.get("hash_type") or []) + out.append(f" {h.get('hash_value')}{f' [{htype}]' if htype else ''}") + out.append(f" {state}") + out.append("") + if urls: + out.append(f"CLOUD URLS & ENDPOINTS ({len(urls)})") + out.append("-" * 70) + for u in urls: + out.append(f" {u.get('url')}") + if u.get("analyzed"): + ports = ", ".join(map(str, u.get("ports") or [])) or "-" + out.append(f" Ports: {ports}") + if u.get("tls_versions"): + out.append(f" TLS: {', '.join(u['tls_versions'])}") + if u.get("cipher_suites"): + out.append(f" Cipher: {', '.join(u['cipher_suites'])}") + else: + out.append(" analysis pending") + out.append("") + + return "\n".join(out) diff --git a/django_project/analyzer/scoring.py b/django_project/analyzer/scoring.py new file mode 100644 index 0000000..3222e36 --- /dev/null +++ b/django_project/analyzer/scoring.py @@ -0,0 +1,80 @@ +"""Central finding deduplication, prioritization, and summary logic.""" + +from __future__ import annotations + +from .findings import Finding, Category, Severity, Confidence + +# Categories highlighted in the report summary. +HIGHLIGHT_CATEGORIES = { + Category.PLAINTEXT_CREDENTIALS, + Category.PASSWORD_HASHES, + Category.TOKENS_SECRETS, + Category.MALWARE_BEACONING, + Category.EXFILTRATION, +} + + +def dedupe(findings: list[Finding]) -> list[Finding]: + """Merge findings with the same ID while retaining their strongest evidence.""" + by_id: dict[str, Finding] = {} + for f in findings: + existing = by_id.get(f.finding_id) + if existing is None: + by_id[f.finding_id] = f + continue + existing.timestamp_first_seen = _min(existing.timestamp_first_seen, f.timestamp_first_seen) + existing.timestamp_last_seen = _max(existing.timestamp_last_seen, f.timestamp_last_seen) + existing.related_packets = sorted(set(existing.related_packets) | set(f.related_packets)) + existing.related_streams = sorted(set(existing.related_streams) | set(f.related_streams)) + if f.confidence.rank > existing.confidence.rank: + existing.confidence = f.confidence + existing.evidence.setdefault("occurrences", 1) + existing.evidence["occurrences"] += 1 + return list(by_id.values()) + + +def sort_by_priority(findings: list[Finding]) -> list[Finding]: + """Sort by severity, confidence, and stable category order.""" + return sorted( + findings, + key=lambda f: (f.severity.rank, f.confidence.rank, -_category_order(f.category)), + reverse=True, + ) + + +def summarize(findings: list[Finding]) -> dict: + """Build the prioritized summary consumed by report renderers.""" + by_sev = {s.value: 0 for s in Severity} + by_cat: dict[str, int] = {} + for f in findings: + by_sev[f.severity.value] += 1 + by_cat[f.category.value] = by_cat.get(f.category.value, 0) + 1 + + highlights = [ + f for f in sort_by_priority(findings) + if f.category in HIGHLIGHT_CATEGORIES + and f.severity.rank >= Severity.HIGH.rank + ] + + return { + "total": len(findings), + "by_severity": by_sev, + "by_category": by_cat, + "highlights": highlights, + "top": sort_by_priority(findings)[:10], + } + + +def _category_order(cat: Category) -> int: + order = list(Category) + return order.index(cat) + + +def _min(a, b): + vals = [x for x in (a, b) if x is not None] + return min(vals) if vals else None + + +def _max(a, b): + vals = [x for x in (a, b) if x is not None] + return max(vals) if vals else None diff --git a/django_project/analyzer/tests/__init__.py b/django_project/analyzer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/analyzer/tests/test_analyzer.py b/django_project/analyzer/tests/test_analyzer.py new file mode 100644 index 0000000..ba23489 --- /dev/null +++ b/django_project/analyzer/tests/test_analyzer.py @@ -0,0 +1,879 @@ +"""Unit tests for the framework-independent analysis core. + +The tests use synthetic capture contexts and therefore require NO +tshark or pyshark. Run with: + + cd django_project + python -m unittest analyzer.tests.test_analyzer -v +""" + +import base64 +import unittest + +from analyzer import AnalysisEngine, render, FORMATS +from analyzer.context import ( + CaptureContext, Conversation, DnsQuery, HttpRequest, HttpResponse, + TlsSession, ArpEvent, PlaintextAuth, AuthMaterial, Host, +) +from analyzer.findings import Finding, Category, Severity, Confidence +from analyzer import scoring + + +def run(ctx): + return AnalysisEngine().analyze_context(ctx) + + +def categories(result): + return {f.category for f in result.findings} + + +class PlaintextCredentialTests(unittest.TestCase): + def test_ftp_plaintext(self): + ctx = CaptureContext() + ctx.plaintext_auth.append(PlaintextAuth( + protocol="ftp", username="admin", password="secret", + src_ip="192.168.1.10", dst_ip="192.168.1.1", dst_port=21)) + res = run(ctx) + self.assertIn(Category.PLAINTEXT_CREDENTIALS, categories(res)) + f = next(f for f in res.findings if f.category == Category.PLAINTEXT_CREDENTIALS) + self.assertEqual(f.severity, Severity.CRITICAL) + self.assertEqual(f.evidence["username"], "admin") + self.assertNotEqual(f.evidence["password"], "secret") # Masked in evidence. + + def test_http_basic_auth(self): + token = base64.b64encode(b"bob:hunter2").decode() + ctx = CaptureContext() + ctx.http_requests.append(HttpRequest( + method="GET", host="example.com", uri="/admin", + authorization=f"Basic {token}", src_ip="10.0.0.5", dst_ip="93.184.216.34", + dst_port=80)) + res = run(ctx) + f = next(f for f in res.findings if "HTTP Basic" in f.title) + self.assertEqual(f.evidence["username"], "bob") + + +class TokenSecretTests(unittest.TestCase): + def test_jwt_detected(self): + jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.abcDEF123_-xyz" + ctx = CaptureContext() + ctx.text_payloads.append({"text": f"Authorization: Bearer {jwt}", + "protocol": "http", "src_ip": "10.0.0.1", + "dst_ip": "1.2.3.4", "packet_number": 7}) + res = run(ctx) + self.assertIn(Category.TOKENS_SECRETS, categories(res)) + + def test_session_cookie_over_http(self): + ctx = CaptureContext() + ctx.text_payloads.append({"text": "Cookie: sessionid=ABCDEF1234567890", + "protocol": "http", "src_ip": "10.0.0.1", + "dst_ip": "1.2.3.4"}) + res = run(ctx) + self.assertTrue(any("Session cookie" in f.title for f in res.findings)) + + +class InsecureProtocolTests(unittest.TestCase): + def test_telnet_flagged_critical(self): + ctx = CaptureContext() + ctx.conversations.append(Conversation( + src_ip="192.168.0.2", dst_ip="192.168.0.3", src_port=50000, dst_port=23, + protocol="tcp", packets=10, bytes=500)) + res = run(ctx) + f = next(f for f in res.findings if f.category == Category.INSECURE_PROTOCOL) + self.assertEqual(f.severity, Severity.CRITICAL) + self.assertEqual(f.port, 23) + + +class TlsWeaknessTests(unittest.TestCase): + def test_old_tls_version(self): + ctx = CaptureContext() + ctx.tls_sessions.append(TlsSession( + src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443, + version="TLS 1.0", cipher="TLS_RSA_WITH_RC4_128_SHA", sni="legacy.example")) + res = run(ctx) + titles = [f.title for f in res.findings if f.category == Category.TLS_WEAKNESS] + self.assertTrue(any("Obsolete TLS" in t for t in titles)) + self.assertTrue(any("cipher" in t.lower() for t in titles)) + + +class DnsAnomalyTests(unittest.TestCase): + def test_dns_tunneling_many_subdomains(self): + ctx = CaptureContext() + for i in range(40): + ctx.dns_queries.append(DnsQuery( + query_name=f"data{i}chunk.tunnel.example.com", rcode="NOERROR", + src_ip="10.0.0.9", dst_ip="8.8.8.8")) + res = run(ctx) + self.assertIn(Category.DNS_ANOMALY, categories(res)) + + def test_high_entropy_domain(self): + ctx = CaptureContext() + ctx.dns_queries.append(DnsQuery( + query_name="a8f3kd92jfh3kd0fj2k3.malware-c2.net", rcode="NOERROR")) + res = run(ctx) + self.assertIn(Category.DNS_ANOMALY, categories(res)) + + +class MitmTests(unittest.TestCase): + def test_arp_spoof_multiple_macs(self): + ctx = CaptureContext() + ctx.arp_events.append(ArpEvent(sender_ip="192.168.1.1", sender_mac="aa:aa:aa:aa:aa:aa", opcode=2)) + ctx.arp_events.append(ArpEvent(sender_ip="192.168.1.1", sender_mac="bb:bb:bb:bb:bb:bb", opcode=2)) + res = run(ctx) + self.assertIn(Category.MITM, categories(res)) + + +class BeaconingTests(unittest.TestCase): + def test_regular_beaconing(self): + ctx = CaptureContext() + conv = Conversation(src_ip="192.168.1.50", dst_ip="8.8.8.8", src_port=44000, + dst_port=443, protocol="tcp") + # Exact 60-second intervals produce a strong periodicity signal. + conv.timestamps = [float(t * 60) for t in range(8)] + conv.first_seen, conv.last_seen = conv.timestamps[0], conv.timestamps[-1] + ctx.conversations.append(conv) + res = run(ctx) + self.assertIn(Category.MALWARE_BEACONING, categories(res)) + + +class ExfiltrationTests(unittest.TestCase): + def test_large_upload(self): + ctx = CaptureContext() + ctx.conversations.append(Conversation( + src_ip="192.168.1.20", dst_ip="8.8.8.8", src_port=40000, dst_port=443, + protocol="tcp", packets=100, bytes=10 * 1024 * 1024, + bytes_src_to_dst=10 * 1024 * 1024)) + res = run(ctx) + self.assertIn(Category.EXFILTRATION, categories(res)) + + +class BruteforceScanTests(unittest.TestCase): + def test_portscan(self): + ctx = CaptureContext() + for port in range(1000, 1030): + ctx.conversations.append(Conversation( + src_ip="10.0.0.66", dst_ip="10.0.0.5", src_port=50000 + port, + dst_port=port, protocol="tcp", packets=1, bytes=60)) + res = run(ctx) + self.assertTrue(any("Port scan" in f.title for f in res.findings)) + + def test_malware_download(self): + ctx = CaptureContext() + ctx.http_requests.append(HttpRequest( + method="GET", host="evil.example", uri="/payload.exe", + src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=80)) + res = run(ctx) + self.assertTrue(any(".exe" in f.title for f in res.findings)) + + +class PasswordHashTests(unittest.TestCase): + def test_ntlm_material(self): + ctx = CaptureContext() + ctx.auth_material.append(AuthMaterial( + kind="ntlmssp", account="user1", domain="CORP", + src_ip="10.0.0.1", dst_ip="10.0.0.2", protocol="ntlm")) + res = run(ctx) + self.assertIn(Category.PASSWORD_HASHES, categories(res)) + + +class ScoringTests(unittest.TestCase): + def _f(self, sev, conf=Confidence.HIGH, key="x"): + return Finding(title="t", category=Category.EXFILTRATION, severity=sev, + confidence=conf, risk_description="r", recommendation="rec", + evidence={"dedup_key": key}) + + def test_dedupe_merges_identical(self): + a = self._f(Severity.HIGH, Confidence.LOW, key="same") + b = self._f(Severity.HIGH, Confidence.HIGH, key="same") + a.related_packets = [1] + b.related_packets = [2] + merged = scoring.dedupe([a, b]) + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0].confidence, Confidence.HIGH) + self.assertEqual(merged[0].related_packets, [1, 2]) + + def test_sort_by_priority(self): + low = self._f(Severity.LOW, key="a") + crit = self._f(Severity.CRITICAL, key="b") + med = self._f(Severity.MEDIUM, key="c") + ordered = scoring.sort_by_priority([low, crit, med]) + self.assertEqual([f.severity for f in ordered], + [Severity.CRITICAL, Severity.MEDIUM, Severity.LOW]) + + +class ReportRenderingTests(unittest.TestCase): + def _result(self): + ctx = CaptureContext(path="x.pcapng", packet_count=10) + ctx.plaintext_auth.append(PlaintextAuth( + protocol="ftp", username="admin", password="secret", + src_ip="192.168.1.10", dst_ip="192.168.1.1", dst_port=21)) + return run(ctx) + + def test_all_formats_render(self): + res = self._result() + for fmt in FORMATS: + out = render(res, fmt) + self.assertIsInstance(out, str) + self.assertTrue(len(out) > 0) + + def test_json_is_valid(self): + import json + res = self._result() + data = json.loads(render(res, "json")) + self.assertIn("findings", data) + self.assertIn("summary", data) + self.assertGreaterEqual(len(data["findings"]), 1) + + def test_extra_cloud_results_in_report(self): + import json + res = self._result() + extra = { + "urls": [{"url": "api.example.com", "analyzed": True, "ports": [443], + "tls_versions": ["TLS 1.2"], "cipher_suites": ["AES_256_GCM"]}], + "hashes": [{"hash_value": "5f4dcc3b5aa765d61d8327deb882cf99", "analyzed": True, + "cracked": True, "cracked_value": "password", "crack_time": "0.10s", + "hash_type": ["md5"]}], + } + md = render(res, "md", extra) + self.assertIn("api.example.com", md) + self.assertIn("password", md) + txt = render(res, "txt", extra) + self.assertIn("CRACKED", txt) + data = json.loads(render(res, "json", extra)) + self.assertIn("cloud_results", data) + self.assertEqual(data["cloud_results"]["hashes"][0]["cracked_value"], "password") + + +class ReconstructionTests(unittest.TestCase): + def test_roundtrip_from_dict_renders(self): + import json + from analyzer import result_from_dict, render + ctx = CaptureContext(path="x.pcapng", packet_count=5) + ctx.plaintext_auth.append(PlaintextAuth( + protocol="ftp", username="admin", password="secret", + src_ip="192.168.1.10", dst_ip="192.168.1.1", dst_port=21)) + res = run(ctx) + # Serialize and reconstruct a stored result. + stored = json.dumps(res.to_dict()) + rebuilt = result_from_dict(json.loads(stored)) + self.assertEqual(len(rebuilt.findings), len(res.findings)) + self.assertEqual(rebuilt.findings[0].finding_id, res.findings[0].finding_id) + # Every renderer must work with the reconstructed result. + for fmt in FORMATS: + out = render(rebuilt, fmt) + self.assertTrue(len(out) > 0) + + +class EngineRobustnessTests(unittest.TestCase): + def test_empty_capture(self): + res = run(CaptureContext(path="empty.pcap")) + self.assertEqual(res.findings, []) + self.assertEqual(res.summary["total"], 0) + + def test_all_ten_detectors_registered(self): + res = run(CaptureContext()) + self.assertEqual(len(res.meta["detectors_run"]), 10) + self.assertEqual(res.meta["detector_errors"], {}) + + +# --------------------------------------------------------------------------- # +# Detector regression tests +# --------------------------------------------------------------------------- # + +class BeaconingConnectionTests(unittest.TestCase): + def test_beaconing_via_distinct_connections(self): + """Detect TCP beaconing spread across multiple short connections.""" + ctx = CaptureContext() + for i in range(8): + t = float(i * 30) # One new connection every 30 seconds. + ctx.conversations.append(Conversation( + src_ip="192.168.1.77", dst_ip="8.8.8.8", + src_port=40000 + i, dst_port=443, protocol="tcp", + packets=2, bytes=200, first_seen=t, last_seen=t + 1, + stream_id=i, timestamps=[t, t + 1], + )) + res = run(ctx) + beacons = [f for f in res.findings if f.category == Category.MALWARE_BEACONING] + self.assertTrue(beacons, "Beaconing across separate connections was not detected") + self.assertEqual(beacons[0].evidence.get("signal"), "distinct_connections") + self.assertEqual(beacons[0].destination_ip, "8.8.8.8") + + def test_irregular_connections_not_flagged(self): + ctx = CaptureContext() + for i, t in enumerate([0.0, 5.0, 90.0, 91.0, 400.0, 402.0]): + ctx.conversations.append(Conversation( + src_ip="192.168.1.77", dst_ip="8.8.8.8", + src_port=40000 + i, dst_port=443, protocol="tcp", + first_seen=t, last_seen=t, timestamps=[t])) + res = run(ctx) + self.assertNotIn(Category.MALWARE_BEACONING, categories(res)) + + +class ProtocolStatsCaseTests(unittest.TestCase): + def test_eapol_handshake_real_casing(self): + """Protocol statistics remain case-insensitive for tshark layer names.""" + ctx = CaptureContext() + ctx.protocol_stats["EAPOL"] = 4 + res = run(ctx) + self.assertTrue(any("EAPOL" in f.title for f in res.findings)) + + def test_smbv1_real_casing(self): + ctx = CaptureContext() + ctx.protocol_stats["SMB"] = 20 + res = run(ctx) + self.assertTrue(any("SMBv1" in f.title for f in res.findings)) + + def test_smbv1_suppressed_when_smb2_present(self): + ctx = CaptureContext() + ctx.protocol_stats["SMB"] = 20 + ctx.protocol_stats["SMB2"] = 100 + res = run(ctx) + self.assertFalse(any("SMBv1" in f.title for f in res.findings)) + + +class TlsCertTests(unittest.TestCase): + def test_self_signed_certificate_flagged(self): + ctx = CaptureContext() + ctx.tls_sessions.append(TlsSession( + src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443, + version="TLS 1.2", sni="iot.local", + cert_subject_cn="iot.local", cert_issuer_cn="iot.local", + cert_self_signed=True)) + res = run(ctx) + self.assertTrue(any("self-signed" in f.title for f in res.findings)) + + def test_expired_certificate_flagged(self): + ctx = CaptureContext() + ctx.tls_sessions.append(TlsSession( + src_ip="10.0.0.1", dst_ip="1.2.3.4", dst_port=443, + version="TLS 1.2", cert_expired=True)) + res = run(ctx) + self.assertTrue(any("expired" in f.title for f in res.findings)) + + +class CertHelperTests(unittest.TestCase): + def test_self_signed_helper(self): + from analyzer.extract_pyshark import _cert_is_self_signed + self.assertTrue(_cert_is_self_signed("device.local", "device.local")) + self.assertFalse(_cert_is_self_signed("device.local", "DigiCert CA")) + self.assertIsNone(_cert_is_self_signed("device.local", None)) + + def test_expired_helper(self): + from analyzer.extract_pyshark import _cert_is_expired + # The certificate expired before the 2025 capture timestamp. + now = 1735689600.0 # 2025-01-01 UTC + self.assertTrue(_cert_is_expired("2020-01-01 00:00:00", now)) + self.assertFalse(_cert_is_expired("2030-01-01 00:00:00", now)) + self.assertIsNone(_cert_is_expired(None, now)) + + def test_parse_cert_time_formats(self): + from analyzer.extract_pyshark import _parse_cert_time + self.assertIsNotNone(_parse_cert_time("2020-01-01 00:00:00")) + self.assertIsNotNone(_parse_cert_time("Jan 1 00:00:00 2020 GMT")) + self.assertIsNotNone(_parse_cert_time("200101000000Z")) + self.assertIsNone(_parse_cert_time("nonsense")) + + +# --------------------------------------------------------------------------- # +# Authentication material reconstruction +# --------------------------------------------------------------------------- # + +class CrackFormatTests(unittest.TestCase): + def test_netntlmv2(self): + from analyzer.crackformats import build_netntlmv2 + nt = "a" * 32 + "b" * 40 # NTProof(16B) + Blob + out = build_netntlmv2("alice", "CORP", "1122334455667788", nt) + self.assertIsNotNone(out) + self.assertEqual(out["hashcat_mode"], 5600) + self.assertEqual(out["hashcat"], + "alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 40) + + def test_netntlmv2_rejects_bad_challenge(self): + from analyzer.crackformats import build_netntlmv2 + self.assertIsNone(build_netntlmv2("alice", "CORP", "1122", "a" * 40)) + + def test_krb5_asrep(self): + from analyzer.crackformats import build_krb5_asrep + cipher = "c" * 32 + "d" * 64 + out = build_krb5_asrep("bob", "corp.local", 23, cipher) + self.assertEqual(out["hashcat_mode"], 18200) + self.assertTrue(out["hashcat"].startswith("$krb5asrep$23$bob@CORP.LOCAL:")) + + def test_krb5_tgsrep(self): + from analyzer.crackformats import build_krb5_tgsrep + cipher = "e" * 32 + "f" * 64 + out = build_krb5_tgsrep("svc", "corp.local", "MSSQL/db", 23, cipher) + self.assertEqual(out["hashcat_mode"], 13100) + self.assertIn("$krb5tgs$23$*svc$CORP.LOCAL$MSSQL/db*$", out["hashcat"]) + + def test_smtp_auth_plain(self): + import base64 + from analyzer.crackformats import decode_smtp_auth_plain + token = base64.b64encode(b"\x00joe\x00s3cret").decode() + self.assertEqual(decode_smtp_auth_plain(token), ("joe", "s3cret")) + + def test_smtp_auth_login(self): + import base64 + from analyzer.crackformats import decode_smtp_auth_login + u = base64.b64encode(b"joe").decode() + p = base64.b64encode(b"s3cret").decode() + self.assertEqual(decode_smtp_auth_login(u, p), ("joe", "s3cret")) + + +class CrackableAuthMaterialTests(unittest.TestCase): + def test_hashcat_material_surfaced_and_high_confidence(self): + ctx = CaptureContext() + ctx.auth_material.append(AuthMaterial( + kind="netntlmv2", account="alice", domain="CORP", + src_ip="10.0.0.5", dst_ip="10.0.0.1", protocol="ntlm", + hashcat="alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 40, + hashcat_mode=5600, hash_format="NetNTLMv2")) + res = run(ctx) + ph = [f for f in res.findings if f.category == Category.PASSWORD_HASHES] + self.assertTrue(ph) + f = ph[0] + self.assertEqual(f.confidence, Confidence.HIGH) + self.assertIn("crackable", f.title) + self.assertEqual(f.evidence.get("hashcat_mode"), 5600) + self.assertTrue(f.evidence.get("hashcat", "").startswith("alice::CORP:")) + + +class MailPlaintextAuthTests(unittest.TestCase): + def test_pop3_plaintext(self): + ctx = CaptureContext() + ctx.plaintext_auth.append(PlaintextAuth( + protocol="pop3", username="joe", password="pw123", + src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=110)) + res = run(ctx) + self.assertTrue(any("POP3" in f.title for f in res.findings)) + + def test_smtp_plaintext(self): + ctx = CaptureContext() + ctx.plaintext_auth.append(PlaintextAuth( + protocol="smtp", username="joe", password="pw123", + src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=25)) + res = run(ctx) + self.assertTrue(any("SMTP" in f.title for f in res.findings)) + + +# --------------------------------------------------------------------------- # +# False-positive regression tests +# --------------------------------------------------------------------------- # + +class UserAgentTests(unittest.TestCase): + def _ua(self, ua): + ctx = CaptureContext() + ctx.http_requests.append(HttpRequest( + method="GET", host="x.example", uri="/", user_agent=ua, + src_ip="10.0.0.5", dst_ip="1.2.3.4", dst_port=80)) + return run(ctx) + + def test_benign_iot_ua_not_flagged(self): + for ua in ("okhttp/4.9.0", "Java/1.8.0", "Go-http-client/1.1"): + res = self._ua(ua) + self.assertFalse( + any(f.category == Category.MALWARE_BEACONING for f in res.findings), + f"{ua} sollte kein Finding erzeugen") + + def test_scanner_ua_flagged_high(self): + res = self._ua("Mozilla/5.00 (Nikto/2.1.6)") + ua = [f for f in res.findings if f.category == Category.MALWARE_BEACONING] + self.assertTrue(ua) + self.assertEqual(ua[0].severity, Severity.HIGH) + self.assertEqual(ua[0].evidence.get("tier"), "scanner") + + def test_scripting_ua_flagged_low(self): + res = self._ua("curl/8.1.2") + ua = [f for f in res.findings if f.category == Category.MALWARE_BEACONING] + self.assertTrue(ua) + self.assertEqual(ua[0].severity, Severity.LOW) + + +class DnsQtypeTests(unittest.TestCase): + def test_qtype_name_mapping(self): + from analyzer.extract_pyshark import _dns_qtype_name + self.assertEqual(_dns_qtype_name("16"), "TXT") + self.assertEqual(_dns_qtype_name("1"), "A") + self.assertEqual(_dns_qtype_name("txt"), "TXT") + self.assertIsNone(_dns_qtype_name(None)) + + def test_txt_tunneling_flagged(self): + ctx = CaptureContext() + for i in range(25): + ctx.dns_queries.append(DnsQuery( + query_name=f"chunk{i}.exfil.example.com", qtype="TXT", + rcode="NOERROR", src_ip="10.0.0.9", dst_ip="8.8.8.8")) + res = run(ctx) + self.assertTrue(any("TXT or NULL" in f.title for f in res.findings)) + + def test_normal_a_queries_no_txt_finding(self): + ctx = CaptureContext() + for i in range(25): + ctx.dns_queries.append(DnsQuery( + query_name=f"host{i}.example.com", qtype="A", rcode="NOERROR")) + res = run(ctx) + self.assertFalse(any("TXT or NULL" in f.title for f in res.findings)) + + +class DownloadHelperTests(unittest.TestCase): + def test_is_download_content_type(self): + from analyzer.extract_pyshark import _is_download_content_type + self.assertTrue(_is_download_content_type("application/octet-stream")) + self.assertTrue(_is_download_content_type("application/x-msdownload")) + self.assertFalse(_is_download_content_type("text/html; charset=utf-8")) + self.assertFalse(_is_download_content_type(None)) + + def test_filename_from_disposition(self): + from analyzer.extract_pyshark import _filename_from_disposition + self.assertEqual(_filename_from_disposition('attachment; filename="update.exe"'), "update.exe") + self.assertEqual(_filename_from_disposition("attachment; filename=payload.bin"), "payload.bin") + self.assertIsNone(_filename_from_disposition("inline")) + + +class FileTransferDownloadTests(unittest.TestCase): + def test_executable_content_type_flagged(self): + from analyzer.context import FileTransfer + ctx = CaptureContext() + ctx.file_transfers.append(FileTransfer( + filename=None, extension=None, content_type="application/x-msdownload", + protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download")) + res = run(ctx) + self.assertTrue(any("harmful file" in f.title for f in res.findings)) + + def test_disposition_filename_extension_flagged(self): + from analyzer.context import FileTransfer + ctx = CaptureContext() + ctx.file_transfers.append(FileTransfer( + filename="update.exe", extension=".exe", content_type="application/octet-stream", + protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download")) + res = run(ctx) + self.assertTrue(any(".exe" in f.title for f in res.findings)) + + def test_benign_download_not_flagged(self): + from analyzer.context import FileTransfer + ctx = CaptureContext() + ctx.file_transfers.append(FileTransfer( + filename="report.pdf", extension=".pdf", content_type="application/pdf", + protocol="http", src_ip="1.2.3.4", dst_ip="10.0.0.5", direction="download")) + res = run(ctx) + self.assertFalse(any(f.category == Category.BRUTEFORCE_SCAN_FILETRANSFER + for f in res.findings)) + + +# --------------------------------------------------------------------------- # +# Packet-level artifact provenance +# --------------------------------------------------------------------------- # + +class ProvenanceHelperTests(unittest.TestCase): + def test_derive_artifact_credentials(self): + from analyzer.provenance import derive_artifact + art = derive_artifact({"username": "joe", "password": "pw"}) + self.assertEqual(art["label"], "Credentials") + self.assertIn("joe", art["value"]) + + def test_derive_artifact_hashcat(self): + from analyzer.provenance import derive_artifact + art = derive_artifact({"hashcat": "alice::CORP:...:...:..."}) + self.assertEqual(art["label"], "Hash (hashcat)") + + def test_derive_artifact_none_for_behavioral(self): + from analyzer.provenance import derive_artifact + self.assertIsNone(derive_artifact({"distinct_ports": 30})) + + def test_build_sources_single_packet_has_timestamp(self): + from analyzer.provenance import build_sources + srcs = build_sources(related_packets=[42], related_streams=[3], + src_ip="10.0.0.5", dst_ip="10.0.0.2", protocol="ftp", + port=21, timestamp_first_seen=1577880000.0, capture="c.pcapng") + self.assertEqual(len(srcs), 1) + self.assertEqual(srcs[0]["packet"], 42) + self.assertEqual(srcs[0]["stream"], 3) + self.assertEqual(srcs[0]["timestamp"], 1577880000.0) + self.assertEqual(srcs[0]["capture"], "c.pcapng") + + def test_build_sources_multi_packet_capped_no_per_packet_ts(self): + from analyzer.provenance import build_sources, MAX_SOURCE_PACKETS + srcs = build_sources(related_packets=list(range(1, 20)), related_streams=[1, 2], + src_ip="10.0.0.5", dst_ip="8.8.8.8", protocol="tcp", + port=443, timestamp_first_seen=100.0, capture="c.pcapng") + self.assertEqual(len(srcs), MAX_SOURCE_PACKETS) + self.assertNotIn("timestamp", srcs[0]) # Never infer per-packet timestamps. + self.assertNotIn("stream", srcs[0]) # Omit an ambiguous stream reference. + + def test_build_sources_no_packet_still_has_reference(self): + from analyzer.provenance import build_sources + srcs = build_sources(related_packets=[], related_streams=[], + src_ip="10.0.0.9", dst_ip=None, protocol="dns", port=53, + timestamp_first_seen=None, capture="c.pcapng") + self.assertEqual(len(srcs), 1) + self.assertIn("note", srcs[0]) + + +class EngineProvenanceTests(unittest.TestCase): + def test_finding_gets_capture_artifact_sources(self): + ctx = CaptureContext(path="/data/demo_capture.pcapng") + ctx.plaintext_auth.append(PlaintextAuth( + protocol="ftp", username="admin", password="s3cret", + src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=21, + timestamp=1577880000.0, packet_number=42, stream_id=3)) + res = run(ctx) + f = next(f for f in res.findings if f.category == Category.PLAINTEXT_CREDENTIALS) + # Store only the capture basename. + self.assertEqual(f.evidence["capture_file"], "demo_capture.pcapng") + self.assertEqual(f.evidence["artifact"]["label"], "Credentials") + self.assertTrue(f.evidence["sources"]) + self.assertEqual(f.evidence["sources"][0]["packet"], 42) + self.assertEqual(f.evidence["sources"][0]["capture"], "demo_capture.pcapng") + + def test_aggregated_findings_have_packets(self): + # Aggregated scan findings retain representative packet numbers. + ctx = CaptureContext(path="cap.pcapng") + for p in range(1000, 1020): + ctx.conversations.append(Conversation( + src_ip="10.0.0.66", dst_ip="10.0.0.5", src_port=50000 + p, + dst_port=p, protocol="tcp", packets=1, bytes=60, + packet_numbers=[p])) + res = run(ctx) + scan = next(f for f in res.findings if "Port scan" in f.title) + self.assertTrue(scan.related_packets, "Port scan has no packet evidence") + self.assertTrue(scan.evidence["sources"]) + self.assertIn("packet", scan.evidence["sources"][0]) + + def test_beaconing_distinct_connections_have_packets(self): + ctx = CaptureContext(path="cap.pcapng") + for i in range(8): + t = float(i * 30) + ctx.conversations.append(Conversation( + src_ip="192.168.1.77", dst_ip="8.8.8.8", src_port=40000 + i, + dst_port=443, protocol="tcp", first_seen=t, last_seen=t, + timestamps=[t], stream_id=i, packet_numbers=[500 + i])) + res = run(ctx) + beacon = next(f for f in res.findings if f.category == Category.MALWARE_BEACONING) + self.assertTrue(beacon.related_packets) + + +class ReportProvenanceTests(unittest.TestCase): + def test_report_shows_artifact_and_packet(self): + ctx = CaptureContext(path="/data/evidence.pcapng") + ctx.plaintext_auth.append(PlaintextAuth( + protocol="ftp", username="admin", password="s3cret", + src_ip="10.0.0.5", dst_ip="10.0.0.2", dst_port=21, + timestamp=1577880000.0, packet_number=42, stream_id=3)) + res = run(ctx) + md = render(res, "md") + self.assertIn("Observed artifact", md) + self.assertIn("Packet #42", md) + self.assertIn("evidence.pcapng", md) + txt = render(res, "txt") + self.assertIn("ARTIFACT", txt) + self.assertIn("Packet #42", txt) + + +# --------------------------------------------------------------------------- # +# Artifact extraction and enrichment integration +# --------------------------------------------------------------------------- # + +class HashModeTests(unittest.TestCase): + def test_length_based_modes(self): + from analyzer.artifacts import identify_hash_mode + self.assertEqual(identify_hash_mode("a" * 32)[0], 0) # md5 + self.assertEqual(identify_hash_mode("a" * 40)[0], 100) # sha1 + self.assertEqual(identify_hash_mode("a" * 64)[0], 1400) # sha256 + self.assertEqual(identify_hash_mode("a" * 128)[0], 1700) # sha512 + + def test_structural_modes(self): + from analyzer.artifacts import identify_hash_mode + netntlmv2 = "alice::CORP:" + "1" * 16 + ":" + "a" * 32 + ":" + "b" * 40 + self.assertEqual(identify_hash_mode(netntlmv2)[0], 5600) + self.assertEqual(identify_hash_mode("$krb5asrep$23$bob@CORP:aaa$bbb")[0], 18200) + self.assertEqual(identify_hash_mode("$krb5tgs$23$*svc$CORP$spn*$a$b")[0], 13100) + self.assertEqual(identify_hash_mode("wpa*01*...")[0], 22000) + + def test_hint_format_wins(self): + from analyzer.artifacts import identify_hash_mode + self.assertEqual(identify_hash_mode("whatever", hint_format="NetNTLMv2")[0], 5600) + self.assertEqual(identify_hash_mode("x", hint_format="krb5asrep(etype=23)")[0], 18200) + + def test_unknown_returns_none(self): + from analyzer.artifacts import identify_hash_mode + self.assertEqual(identify_hash_mode("nothash"), (None, None)) + self.assertEqual(identify_hash_mode("a" * 33), (None, None)) + + +class ArtifactExtractionTests(unittest.TestCase): + def test_extract_urls_dedupes_and_observes(self): + from analyzer import extract_urls + ctx = CaptureContext() + ctx.dns_queries.append(DnsQuery(query_name="api.example.com.", src_ip="10.0.0.5")) + ctx.tls_sessions.append(TlsSession(src_ip="10.0.0.5", dst_ip="1.2.3.4", + dst_port=443, version="TLS 1.2", sni="api.example.com")) + ctx.http_requests.append(HttpRequest(method="GET", host="cdn.example.net", + uri="/x", src_ip="10.0.0.5", dst_ip="1.2.3.4", dst_port=80)) + urls = extract_urls(ctx) + vals = {u.value for u in urls} + self.assertIn("api.example.com", vals) + self.assertIn("cdn.example.net", vals) + # DNS and SNI observations merge into one endpoint artifact. + self.assertEqual(len([u for u in urls if u.value == "api.example.com"]), 1) + api = next(u for u in urls if u.value == "api.example.com") + self.assertIn(443, api.observed_ports) + self.assertIn("TLS 1.2", api.observed_tls) + + def test_extract_hashes_from_auth_material_and_payload(self): + from analyzer import extract_hashes + ctx = CaptureContext() + ntlm = "alice::CORP:" + "1" * 16 + ":" + "a" * 32 + ":" + "b" * 40 + ctx.auth_material.append(AuthMaterial( + kind="netntlmv2", account="alice", hashcat=ntlm, hashcat_mode=5600, + hash_format="NetNTLMv2", src_ip="10.0.0.5", packet_number=7)) + ctx.text_payloads.append({"text": "token=" + "d" * 64, "protocol": "http", + "src_ip": "10.0.0.5", "packet_number": 9}) + hashes = extract_hashes(ctx) + modes = {h.hashcat_mode for h in hashes} + self.assertIn(5600, modes) # NTLM + self.assertIn(1400, modes) # SHA-256 extracted from payload text. + ntlm_art = next(h for h in hashes if h.hashcat_mode == 5600) + self.assertEqual(ntlm_art.account, "alice") + self.assertEqual(ntlm_art.kind, "netntlmv2") + + +class DemoPayloadRegressionTests(unittest.TestCase): + """Extract hashes and URLs from generic HTTP body payloads.""" + + def test_hashes_and_urls_from_body_payload(self): + from analyzer import extract_urls, extract_hashes + ctx = CaptureContext() + ctx.text_payloads.append({"text": ( + "POST /hashes HTTP/1.1\r\nHost: testserver\r\n\r\n" + "5f4dcc3b5aa765d61d8327deb882cf99\n" # md5 + "e27f400cc2fe611bbb129ae0b4b4be6b32debf12\n" # sha1 + "0e2cfcd348361a44a6f3e646552cb56d8d4d9555b4bcd89770f96983d8f321ad" # sha256 + ), "protocol": "http", "packet_number": 1}) + ctx.text_payloads.append({"text": ( + "GET /allowed_urls HTTP/1.1\r\nHost: testserver\r\n\r\n" + "eu.iot.dreame.tech\n" + "https://app.mt.eu.iot.dreame.tech\n" + "http://40.101.70.178/\n" + ), "protocol": "http", "packet_number": 2}) + + hashes = extract_hashes(ctx) + self.assertEqual(len(hashes), 3) + self.assertEqual({h.hashcat_mode for h in hashes}, {0, 100, 1400}) + + urls = {u.value for u in extract_urls(ctx)} + self.assertIn("eu.iot.dreame.tech", urls) + self.assertIn("https://app.mt.eu.iot.dreame.tech", urls) + self.assertTrue(any("40.101.70.178" in u for u in urls)) + + def test_hexish_payload_decoder(self): + from analyzer.extract_pyshark import _bytes_from_hexish + # pyshark commonly exposes payload bytes as colon-separated hex. + self.assertEqual(_bytes_from_hexish("47:45:54"), "GET") + self.assertEqual(_bytes_from_hexish("already text"), "already text") + + +class HashCrackTests(unittest.TestCase): + def _runner_factory(self, show_stdout): + import types + calls = {"n": 0} + def runner(cmd, **kw): + calls["n"] += 1 + # The second invocation reads the cracked result. + out = show_stdout if "--show" in cmd else "" + return types.SimpleNamespace(stdout=out, stderr="", returncode=0) + return runner, calls + + def test_command_builders_and_parser(self): + from analyzer.enrich.hash_crack import build_crack_command, build_show_command, parse_show_output + cmd = build_crack_command("hashcat", 5600, "/tmp/h.txt", ["/wl/rockyou.txt"], "/tmp/pot") + self.assertEqual(cmd[:3], ["hashcat", "-m", "5600"]) + self.assertIn("--potfile-path", cmd) + self.assertIn("--show", build_show_command("hashcat", 0, "/tmp/h.txt", "/tmp/pot")) + self.assertEqual(parse_show_output("abc123:hunter2"), "hunter2") + self.assertIsNone(parse_show_output("")) + + def test_crack_success(self): + from analyzer.artifacts import HashArtifact + from analyzer.enrich.hash_crack import DEFAULT_WORDLISTS, crack_hash + runner, _ = self._runner_factory(show_stdout="deadbeef" * 8 + ":hunter2\n") + art = HashArtifact(value="d" * 64, hashcat_mode=1400, hash_format="sha256") + res = crack_hash(art, wordlists=DEFAULT_WORDLISTS, hashcat_path="hashcat", + runner=runner, which=lambda p: "/usr/bin/hashcat") + self.assertTrue(res.cracked) + self.assertEqual(res.plaintext, "hunter2") + self.assertEqual(res.state, "cracked") + + def test_crack_not_found(self): + from analyzer.artifacts import HashArtifact + from analyzer.enrich.hash_crack import DEFAULT_WORDLISTS, crack_hash + runner, _ = self._runner_factory(show_stdout="") + art = HashArtifact(value="d" * 64, hashcat_mode=1400) + res = crack_hash(art, wordlists=DEFAULT_WORDLISTS, runner=runner, + which=lambda p: "/usr/bin/hashcat") + self.assertFalse(res.cracked) + self.assertEqual(res.state, "not_cracked") + + def test_crack_unsupported_mode(self): + from analyzer.enrich.hash_crack import crack_hash + res = crack_hash("not-a-hash", which=lambda p: "/usr/bin/hashcat") + self.assertEqual(res.state, "unsupported") + + def test_crack_hashcat_missing(self): + from analyzer.artifacts import HashArtifact + from analyzer.enrich.hash_crack import crack_hash + art = HashArtifact(value="d" * 64, hashcat_mode=1400) + res = crack_hash(art, wordlists=["/etc/hostname"], which=lambda p: None) + self.assertEqual(res.state, "error") + + +class UrlProbeTests(unittest.TestCase): + def test_host_of(self): + from analyzer.enrich.url_probe import host_of + self.assertEqual(host_of("https://api.example.com/x?y=1"), "api.example.com") + self.assertEqual(host_of("example.net:8080/path"), "example.net") + self.assertEqual(host_of("plain.host"), "plain.host") + + def test_passive_returns_observed(self): + from analyzer.artifacts import UrlArtifact + from analyzer.enrich.url_probe import probe_url + art = UrlArtifact(value="api.example.com", kind="sni", + observed_ports={443}, observed_tls={"TLS 1.2"}) + probe = probe_url(art, active=False) + self.assertFalse(probe.active) + self.assertEqual(probe.open_ports, [443]) + self.assertEqual(probe.tls_versions, ["TLS 1.2"]) + + def test_active_merges_injected_scanners(self): + from analyzer.artifacts import UrlArtifact + from analyzer.enrich.url_probe import probe_url + art = UrlArtifact(value="api.example.com", observed_ports={443}) + probe = probe_url( + art, active=True, + dns_resolver=lambda h: ["93.184.216.34"], + port_scanner=lambda h, ports: [80, 443], + tls_scanner=lambda h: {"tls_version": "TLS 1.3", "cipher": "TLS_AES_128_GCM_SHA256", + "certificate": "Present"}, + ) + self.assertTrue(probe.active) + self.assertEqual(probe.resolved_ips, ["93.184.216.34"]) + self.assertEqual(probe.open_ports, [80, 443]) + self.assertIn("TLS 1.3", probe.tls_versions) + self.assertEqual(probe.certificate, "Present") + + +class EnrichmentEngineTests(unittest.TestCase): + def test_engine_progress_and_results(self): + from analyzer.artifacts import HashArtifact + from analyzer.enrich import EnrichmentEngine + seen = [] + arts = [HashArtifact(value="d" * 64, hashcat_mode=1400), + HashArtifact(value="nope", hashcat_mode=None)] + res = EnrichmentEngine().crack_hashes( + arts, progress=lambda i, t, item: seen.append((i, t)), + which=lambda p: None) # Avoid a real subprocess in this unit test. + self.assertEqual(len(res), 2) + self.assertEqual(seen, [(1, 2), (2, 2)]) + self.assertEqual(res[1].state, "unsupported") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/django_project/create_models.py b/django_project/create_models.py new file mode 100644 index 0000000..895a02b --- /dev/null +++ b/django_project/create_models.py @@ -0,0 +1,65 @@ +"""Seed the IoT PreTester 4.6 Demo reference data.""" + +import os + +import django +from django.db import transaction + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings") +django.setup() + +from IoTAnalyzer.models import Device, DeviceType, Manufacturer, Product # noqa: E402 + + +DEVICE_TYPES = ( + "Camera", + "Smart Plug", + "Smart Bulb", + "Smart Switch", + "Robot Vacuum Cleaner", + "Smart Lock", + "Smart Controller", + "Sensor", +) + +MANUFACTURERS = ( + "Aqara", + "Dreame", + "DJI", + "IKEA", + "Nuki", + "Philips Hue", + "Roborock", + "Shelly", + "Sonoff", + "TP-Link", + "X-Sense", +) + + +@transaction.atomic +def populate_database() -> None: + """Create stable reference rows and one sample device without duplicates.""" + for name in DEVICE_TYPES: + DeviceType.objects.get_or_create(device_type=name) + + for name in MANUFACTURERS: + Manufacturer.objects.get_or_create(name=name) + + manufacturer = Manufacturer.objects.get(name="Shelly") + device_type = DeviceType.objects.get(device_type="Smart Controller") + product, _ = Product.objects.get_or_create( + manufacturer=manufacturer, + device_type=device_type, + product_name="1PM Gen3", + ) + Device.objects.get_or_create( + product=product, + serial="DEMO-001", + defaults={"comment": "IoT PreTester 4.6 Demo sample device"}, + ) + print("Demo reference data synchronized") + + +if __name__ == "__main__": + populate_database() diff --git a/django_project/manage.py b/django_project/manage.py new file mode 100755 index 0000000..bac1f2b --- /dev/null +++ b/django_project/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "IoTPreTester.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b79e0e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +name: iot-pretester-46-demo + +services: + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: iot-pretester-46-demo-backend + restart: unless-stopped + user: "1000:1000" + ports: + - "${BACKEND_PORT:-8000}:8000" + volumes: + - backend_data:/app/data + - backend_logs:/app/logs + - capture_uploads:/app/files/uploads + - backend_staticfiles:/app/django_project/staticfiles + environment: + DEBUG: ${DEBUG:-False} + SECRET_KEY: ${SECRET_KEY} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} + DJANGO_SETTINGS_MODULE: IoTPreTester.settings + DATABASE_PATH: /app/data/db.sqlite3 + CAPTURE_UPLOAD_DIR: /app/files/uploads + CORS_ALLOW_ALL_ORIGINS: ${CORS_ALLOW_ALL_ORIGINS:-False} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME:-admin} + DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL:-admin@example.com} + DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD} + INIT_DATABASE: ${INIT_DATABASE:-true} + GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4} + GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-600} + LOG_LEVEL: ${LOG_LEVEL:-info} + networks: + - demo-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} + container_name: iot-pretester-46-demo-frontend + restart: unless-stopped + user: "1000:1000" + ports: + - "${FRONTEND_PORT:-3000}:3000" + environment: + NODE_ENV: ${NODE_ENV:-production} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000/api} + HOSTNAME: 0.0.0.0 + depends_on: + backend: + condition: service_healthy + networks: + - demo-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/login"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + +networks: + demo-network: + driver: bridge + +volumes: + backend_data: + backend_logs: + backend_staticfiles: + capture_uploads: diff --git a/docker/entrypoint-backend.sh b/docker/entrypoint-backend.sh new file mode 100644 index 0000000..88a0b69 --- /dev/null +++ b/docker/entrypoint-backend.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +echo "Starting IoT PreTester 4.6 Demo backend" + +mkdir -p /app/data /app/files/uploads /app/logs /app/django_project/staticfiles +cd /app/django_project + +python manage.py migrate --noinput +python manage.py collectstatic --noinput + +# Keep the configured demo administrator reproducible across container restarts. +python manage.py shell <<'PY' +import os + +from django.contrib.auth import get_user_model +from django.utils.timezone import now + +User = get_user_model() +username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin") +email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@example.com") +password = os.environ["DJANGO_SUPERUSER_PASSWORD"] + +user, created = User.objects.get_or_create(username=username, defaults={"email": email}) +user.email = email +user.is_superuser = True +user.is_staff = True +user.is_active = True +user.set_password(password) +user.last_login = now() +user.save() +print("Demo administrator created" if created else "Demo administrator synchronized") +PY + +if [ "${INIT_DATABASE:-true}" = "true" ]; then + python create_models.py +fi + +exec gunicorn IoTPreTester.wsgi:application \ + --bind 0.0.0.0:8000 \ + --workers "${GUNICORN_WORKERS:-4}" \ + --timeout "${GUNICORN_TIMEOUT:-120}" \ + --access-logfile /app/logs/access.log \ + --error-logfile /app/logs/error.log \ + --log-level "${LOG_LEVEL:-info}" diff --git a/docker/entrypoint-frontend.sh b/docker/entrypoint-frontend.sh new file mode 100644 index 0000000..6492f62 --- /dev/null +++ b/docker/entrypoint-frontend.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +echo "Starting IoT PreTester 4.6 Demo frontend" +exec "$@" diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..3350ae4 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "ignorePatterns": ["next-env.d.ts"] +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..2f4b7b7 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..8919db6 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next" + +const nextConfig: NextConfig = { + output: "standalone", +} + +export default nextConfig diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..669d4a2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6395 @@ +{ + "name": "iot-pre-tester-4-6-demo-frontend", + "version": "4.6.0-demo.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "iot-pre-tester-4-6-demo-frontend", + "version": "4.6.0-demo.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.5", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-progress": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.5", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-toast": "^1.2.5", + "axios": "^1.16.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "next": "15.5.20", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-dropzone": "^14.3.5", + "tailwind-merge": "^2.5.5", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^20.17.16", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "eslint": "^8", + "eslint-config-next": "15.5.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.20.tgz", + "integrity": "sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.20.tgz", + "integrity": "sha512-MZUgFpVd9rGSZpb8bNceUWvkAZe6aQw/6h2SSqHQuYzKfaiEUEVPIO6mXqaBmiBEBSN1f1sOc1uV8GCM90oegg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.20.tgz", + "integrity": "sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.20.tgz", + "integrity": "sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.20.tgz", + "integrity": "sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.20.tgz", + "integrity": "sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.20.tgz", + "integrity": "sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.20.tgz", + "integrity": "sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.20.tgz", + "integrity": "sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.20.tgz", + "integrity": "sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.5.tgz", + "integrity": "sha512-LaO3e5h/NOEL4OfXjxD43k9Dx+vn+8n+PCFt6uhX/BADFflllyv3WJG6rgvvSVBxpTch938Qq/LGc2MMxipXPw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.4", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz", + "integrity": "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz", + "integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz", + "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", + "integrity": "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", + "integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.5.tgz", + "integrity": "sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.4", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.5.tgz", + "integrity": "sha512-ZzUsAaOx8NdXZZKcFNDhbSlbsCUy8qQWmzTdgrlrhhZAOx2ofLtKrBDW9fkqhFvXgmtv560Uj16pkLkqML7SHA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.4", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz", + "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==", + "dev": true + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.17.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz", + "integrity": "sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/react": { + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", + "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "devOptional": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz", + "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", + "devOptional": true, + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.0.tgz", + "integrity": "sha512-NR2yS7qUqCL7AIxdJUQf2MKKNDVNaig/dEB0GBLU7D+ZdHgK1NoH/3wsgO3OnPVipn51tG3MAwaODEGil70WEw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.18.0", + "@typescript-eslint/type-utils": "8.18.0", + "@typescript-eslint/utils": "8.18.0", + "@typescript-eslint/visitor-keys": "8.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.0.tgz", + "integrity": "sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.18.0", + "@typescript-eslint/types": "8.18.0", + "@typescript-eslint/typescript-estree": "8.18.0", + "@typescript-eslint/visitor-keys": "8.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.0.tgz", + "integrity": "sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.18.0", + "@typescript-eslint/visitor-keys": "8.18.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.0.tgz", + "integrity": "sha512-er224jRepVAVLnMF2Q7MZJCq5CsdH2oqjP4dT7K6ij09Kyd+R21r7UVJrF0buMVdZS5QRhDzpvzAxHxabQadow==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.18.0", + "@typescript-eslint/utils": "8.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.0.tgz", + "integrity": "sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.0.tgz", + "integrity": "sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.18.0", + "@typescript-eslint/visitor-keys": "8.18.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.0.tgz", + "integrity": "sha512-p6GLdY383i7h5b0Qrfbix3Vc3+J2k6QWw6UMUeY5JGfm3C5LbZ4QIZzJNoNOfgyRe0uuYKjvVOsO/jD4SJO+xg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.18.0", + "@typescript-eslint/types": "8.18.0", + "@typescript-eslint/typescript-estree": "8.18.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.0.tgz", + "integrity": "sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.18.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz", + "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001687", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz", + "integrity": "sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", + "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz", + "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.3", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.20.tgz", + "integrity": "sha512-Pl/I5544gmkcVVWcnaOMfhJSBK8lZ1NCJ+mGBkd2qvbGt2Gi7sEpgHF06OR13a2p6THODlncpvGsZzY2vUqwxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.20", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.7.0.tgz", + "integrity": "sha512-Vrwyi8HHxY97K5ebydMtffsWAn1SCR9eol49eCd5fJS4O1WV7PaAjbcjmbfJJSMz/t4Mal212Uz/fQZrOB8mow==", + "dev": true, + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.3.7", + "enhanced-resolve": "^5.15.0", + "fast-glob": "^3.3.2", + "get-tsconfig": "^4.7.5", + "is-bun-module": "^1.0.2", + "is-glob": "^4.0.3", + "stable-hash": "^0.0.4" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "dev": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz", + "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.1.0", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.0", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.11", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", + "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.0.tgz", + "integrity": "sha512-kR5g0+dXf/+kXnqI+lu0URKYPKgICtHGGNCDSB10AaUFj3o/HkB3u7WfpRBJGFopxxY0oH3ux7ZsDjLtK7xqvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-1.3.0.tgz", + "integrity": "sha512-DgXeu5UWI0IsMQundYb5UAOzm6G2eVnarJ0byP6Tm55iZNKceD59LNPA2L4VvsScTtHcw0yEkVwSf7PC+QoLSA==", + "dev": true, + "dependencies": { + "semver": "^7.6.3" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.0.tgz", + "integrity": "sha512-qfMdqbAQEwBw78ZyReKnlA8ezmPdb9BemzIIip/JkjaZUhitfXDkkr+3QTboW0JrSXT1QWyYShpvnNHGZ4c4yA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.0.tgz", + "integrity": "sha512-KVSZV0Dunv9DTPkhXwcZ3Q+tUc9TsaE1ZwX5J2WMvsSGS6Md8TFPun5uwh0yRdrNerI6vf/tbJxqSx4c1ZI1Lw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.0.tgz", + "integrity": "sha512-B6ohK4ZmoftlUe+uvenXSbPJFo6U37BH7oO1B3nQH8f/7h27N56s85MhUtbFJAziz5dcmuR3i8ovUl35zp8pFA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "gopd": "^1.1.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.0.tgz", + "integrity": "sha512-PlfzajuF9vSo5wErv3MJAKD/nqf9ngAs1NFQYm16nUYFO2IzxJ2hcm+IOCg+EEopdykNNUhVq5cz35cAUxU8+g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.0.tgz", + "integrity": "sha512-qS8KkNNXUZ/I+nX6QT8ZS1/Yx0A444yhzdTKxCzKkNjQ9sHErBxJnJAgh+f5YhusYECEcjo4XcyH87hn6+ks0A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "has-symbols": "^1.0.3", + "safe-regex-test": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/iterator.prototype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.3.tgz", + "integrity": "sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/next": { + "version": "15.5.20", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.20.tgz", + "integrity": "sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.20", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.20", + "@next/swc-darwin-x64": "15.5.20", + "@next/swc-linux-arm64-gnu": "15.5.20", + "@next/swc-linux-arm64-musl": "15.5.20", + "@next/swc-linux-x64-gnu": "15.5.20", + "@next/swc-linux-x64-musl": "15.5.20", + "@next/swc-win32-arm64-msvc": "15.5.20", + "@next/swc-win32-x64-msvc": "15.5.20", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.5", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz", + "integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-remove-scroll": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", + "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.8.tgz", + "integrity": "sha512-B5dj6usc5dkk8uFliwjwDHM8To5/QwdKz9JcBZ8Ic4G1f0YmeeJTtE/ZTdgRFPAfxZFiUaPhZ1Jcs4qeagItGQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "dunder-proto": "^1.0.0", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.2.0", + "which-builtin-type": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz", + "integrity": "sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==", + "dev": true + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", + "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "regexp.prototype.flags": "^1.5.2", + "set-function-name": "^2.0.2", + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.5.tgz", + "integrity": "sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.16.tgz", + "integrity": "sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.3.tgz", + "integrity": "sha512-GsvTyUHTriq6o/bHcTd0vM7OQ9JEdlvluu9YISaA7+KzDzPaIzEeDFNkTfhdE3MYcNhNi0vq/LlegYgIs5yPAw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.0.tgz", + "integrity": "sha512-Ei7Miu/AXe2JJ4iNF5j/UphAgRoma4trE6PtisM09bPygb3egMH3YLW/befsWb1A1AxvNSFidOFTB18XtnIIng==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.0", + "is-number-object": "^1.1.0", + "is-string": "^1.1.0", + "is-symbol": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.0.tgz", + "integrity": "sha512-I+qLGQ/vucCby4tf5HsLmGueEla4ZhwTBSqaooS+Y0BuxN4Cp+okmGuV+8mXZ84KDI9BA+oklo+RzKg0ONdSUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", + "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e590ffd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "iot-pre-tester-4-6-demo-frontend", + "version": "4.6.0-demo.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.5", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-progress": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.5", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-toast": "^1.2.5", + "axios": "^1.16.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "next": "15.5.20", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-dropzone": "^14.3.5", + "tailwind-merge": "^2.5.5", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^20.17.16", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "eslint": "^8", + "eslint-config-next": "15.5.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.1", + "typescript": "^5" + }, + "overrides": { + "brace-expansion@<2": "1.1.13", + "brace-expansion@>=2 <3": "2.0.3", + "postcss": "$postcss" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/frontend/public/images/devices/camera.png b/frontend/public/images/devices/camera.png new file mode 100644 index 0000000..39b9a43 Binary files /dev/null and b/frontend/public/images/devices/camera.png differ diff --git a/frontend/public/images/devices/robot_vacuum_cleaner.png b/frontend/public/images/devices/robot_vacuum_cleaner.png new file mode 100644 index 0000000..92aeefc Binary files /dev/null and b/frontend/public/images/devices/robot_vacuum_cleaner.png differ diff --git a/frontend/public/images/devices/sensor.png b/frontend/public/images/devices/sensor.png new file mode 100644 index 0000000..cd9510d Binary files /dev/null and b/frontend/public/images/devices/sensor.png differ diff --git a/frontend/public/images/devices/smart_bulb.png b/frontend/public/images/devices/smart_bulb.png new file mode 100644 index 0000000..4e3ea7f Binary files /dev/null and b/frontend/public/images/devices/smart_bulb.png differ diff --git a/frontend/public/images/devices/smart_controller.png b/frontend/public/images/devices/smart_controller.png new file mode 100644 index 0000000..51f70e6 Binary files /dev/null and b/frontend/public/images/devices/smart_controller.png differ diff --git a/frontend/public/images/devices/smart_lock.png b/frontend/public/images/devices/smart_lock.png new file mode 100644 index 0000000..8eeca3a Binary files /dev/null and b/frontend/public/images/devices/smart_lock.png differ diff --git a/frontend/public/images/devices/smart_plug.png b/frontend/public/images/devices/smart_plug.png new file mode 100644 index 0000000..45daa8f Binary files /dev/null and b/frontend/public/images/devices/smart_plug.png differ diff --git a/frontend/public/images/devices/smart_switch.png b/frontend/public/images/devices/smart_switch.png new file mode 100644 index 0000000..b162eaa Binary files /dev/null and b/frontend/public/images/devices/smart_switch.png differ diff --git a/frontend/public/images/logos/Logo.png b/frontend/public/images/logos/Logo.png new file mode 100644 index 0000000..68437fe Binary files /dev/null and b/frontend/public/images/logos/Logo.png differ diff --git a/frontend/public/images/logos/Placeholder.png b/frontend/public/images/logos/Placeholder.png new file mode 100644 index 0000000..2ec2adf Binary files /dev/null and b/frontend/public/images/logos/Placeholder.png differ diff --git a/frontend/src/app/devices/[id]/page.tsx b/frontend/src/app/devices/[id]/page.tsx new file mode 100644 index 0000000..74cba37 --- /dev/null +++ b/frontend/src/app/devices/[id]/page.tsx @@ -0,0 +1,196 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import { useEffect, useState } from "react" +import { useRouter, useParams } from "next/navigation" +import { ArrowLeft, FileIcon, Upload, LogOut } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { ProtectedRoute } from "@/components/protected-route" +import { FileUpload } from "@/components/file-upload" +import type { Device, DeviceFile } from "@/types/device" +import { ScrollArea } from "@/components/ui/scroll-area" +import { getDeviceImage } from "@/components/device-images" +import axios from "axios" +import Image from "next/image" + +interface ApiCapture { + id: number + filename: string + upload: string + firmware_version: string + ip: string + state: string +} + +const getDeviceById = async (deviceId: string) => { + try { + const deviceResponse = await axios.get(getApiUrl(`get_device_by_id/${deviceId}`)) + const deviceData = deviceResponse.data + + const filesResponse = await axios.get(getApiUrl(`get_file_by_device/${deviceId}`)) + const filesData = filesResponse.data + + const files: DeviceFile[] = filesData.map((file: ApiCapture) => ({ + id: String(file.id), + name: file.filename, + uploadedAt: file.upload, + firmwareVersion: file.firmware_version, + ipAddress: file.ip, + status: file.state === "pending" ? "pending" : "scanned", + })) + + return { + id: String(deviceData.id), + name: deviceData.product.product_name, + type: deviceData.product.device_type.device_type, + manufacturer: deviceData.product.manufacturer.name, + serialNumber: deviceData.serial, + comment: deviceData.comment, + lastScanned: new Date(deviceData.created).toLocaleDateString("en-GB"), + files, + } + } catch (error) { + console.error("Failed to fetch device details:", error) + return null + } +} + +export default function DeviceDetailPage() { + const params = useParams() + const id = params?.id as string + const router = useRouter() + const [device, setDevice] = useState(null) + const [showUpload, setShowUpload] = useState(false) + + useEffect(() => { + const fetchDeviceDetails = async () => { + const deviceDetails = await getDeviceById(id) + if (!deviceDetails) { + router.push("/devices") + } else { + setDevice(deviceDetails) + } + } + + fetchDeviceDetails() + }, [id, router]) + + const handleViewResults = (fileId: string) => { + router.push(`/scan-results?fileId=${fileId}`) + } + + if (!device) { + return
+ } + + return ( + + {({ logout }) => ( +
+
+
+ + +
+ +
+ {!showUpload && ( +
+
+ {device.name} + + Type: {device.type} + Manufacturer: {device.manufacturer} + Serial Number: {device.serialNumber} + {device.comment && Comment: {device.comment}} + {device.lastScanned && ( + Last Scanned: {device.lastScanned} + )} + +
+
+ {device.name} +
+
+ )} +
+ + {showUpload ? ( + setShowUpload(false)} deviceId={id} /> + ) : ( +
+ + +
+

Device Files

+ + {device.files && device.files.length > 0 ? ( +
+ {device.files.map((file) => ( +
+
+ +
+

{file.name}

+
+

Uploaded: {new Date(file.uploadedAt).toLocaleDateString("de-DE")}

+ {file.firmwareVersion &&

Firmware: {file.firmwareVersion}

} + {file.ipAddress &&

IP Address: {file.ipAddress}

} +
+
+
+ +
+ ))} +
+ ) : ( +
+ No files uploaded yet. Upload a file to start scanning. +
+ )} +
+
+ +
+ +
+
+ )} +
+
+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/app/devices/page.tsx b/frontend/src/app/devices/page.tsx new file mode 100644 index 0000000..93d643b --- /dev/null +++ b/frontend/src/app/devices/page.tsx @@ -0,0 +1,382 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Plus, Trash2, LogOut, ChevronRight, Users, Settings } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ProtectedRoute } from "@/components/protected-route" +import { useAuth } from "@/lib/auth-context" +import { ScrollArea } from "@/components/ui/scroll-area" +import type { Device } from "@/types/device" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import Image from "next/image" +import { getAllDevices, getManufacturers, getDeviceTypes, addDevice, deleteDevice } from "@/services/apiIOT" + +interface SelectOption { + value: string + label: string +} + +export default function DevicesPage() { + const router = useRouter() + const { user, logout } = useAuth() + const [devices, setDevices] = useState([]) + const [newDeviceName, setNewDeviceName] = useState("") + const [newDeviceType, setNewDeviceType] = useState("") + const [newManufacturer, setNewManufacturer] = useState("") + const [newSerialNumber, setNewSerialNumber] = useState("") + const [newComment, setNewComment] = useState("") + const [dialogOpen, setDialogOpen] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deviceToDelete, setDeviceToDelete] = useState(null) + const [deviceTypes, setDeviceTypes] = useState([]) + const [manufacturers, setManufacturers] = useState([]) + + useEffect(() => { + const fetchDeviceTypes = async () => { + const types = await getDeviceTypes() + setDeviceTypes(types) + } + fetchDeviceTypes() + }, []) + + useEffect(() => { + const fetchManufacturers = async () => { + const types = await getManufacturers() + setManufacturers(types) + } + fetchManufacturers() + }, []) + + useEffect(() => { + const fetchDevices = async () => { + const fetchedDevices = await getAllDevices() + setDevices(fetchedDevices) + } + fetchDevices() + }, []) + + useEffect(() => { + if (!user) { + router.push("/login") + } + }, [user, router]) + + const handleLogout = async () => { + try { + await fetch(getApiUrl("logout"), { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }) + } catch (error) { + console.error("Error during logout:", error) + } finally { + logout() + router.push("/login") + } + } + + const handleAddDevice = async () => { + if (newDeviceName && newDeviceType && newManufacturer && newSerialNumber) { + const newDevice = { + device_name: newDeviceName, + device_type_id: newDeviceType, + manufacturer_id: newManufacturer, + serial: newSerialNumber, + comment: newComment, + } + try { + await addDevice(newDevice) + setDialogOpen(false) + const updatedDevices = await getAllDevices() + setDevices(updatedDevices) + } catch (error) { + console.error("Failed to add device:", error) + } + } + } + + const handleDeleteClick = (id: string) => { + setDeviceToDelete(id) + setDeleteDialogOpen(true) + } + + const confirmDelete = async () => { + if (deviceToDelete) { + try { + await deleteDevice(deviceToDelete) + const updatedDevices = await getAllDevices() + setDevices(updatedDevices) + setDeleteDialogOpen(false) + setDeviceToDelete(null) + } catch (error) { + console.error("Failed to delete device:", error) + } + } + } + + const navigateToDevice = (deviceId: string) => { + router.push(`/devices/${deviceId}`) + } + + if (!user) { + return null + } + + return ( + +
+
+
+
+ + +
+ IoT PreTester 4.6 Demo logo +
+
+ +
+
+ My Devices + Manage and monitor your IoT devices +
+
+ + + + + + + Add New Device + Enter the details of your new IoT device. + +
+
+ + setNewDeviceName(e.target.value)} + placeholder="Enter device name" + className="text-foreground" + /> +
+
+ + +
+
+ + +
+
+ + setNewSerialNumber(e.target.value)} + placeholder="Enter serial number" + className="text-foreground" + /> +
+
+ + setNewComment(e.target.value)} + placeholder="Enter optional comment" + className="text-foreground" + /> +
+
+ + + + +
+
+ {(user.role === "admin" || user.role === "superuser") && ( + <> + + + + + + + )} +
+
+ + +
+ {devices.map((device) => ( + +
+
+ {/**/} +
+
+ +
+
+ {device.name} + + + {deviceTypes.find((t) => t.value === device.type)?.label || device.type} + + + Manufacturer:{" "} + {manufacturers.find((m) => m.value === device.manufacturer)?.label || + device.manufacturer} + + Serial Number: {device.serialNumber} + {device.comment && Comment: {device.comment}} + +
+ +
+
+ +
+
+ {device.files?.length || 0} files + {device.lastScanned && ` • Last scan: ${device.lastScanned}`} +
+ +
+
+
+ ))} + {devices.length === 0 && ( +
+ No devices added yet. Add your first device to get started. +
+ )} +
+
+
+
+
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Device + + Are you sure you want to delete this device? This action cannot be undone. + + + + + + + + +
+ ) +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/findings/page.tsx b/frontend/src/app/findings/page.tsx new file mode 100644 index 0000000..f9a61de --- /dev/null +++ b/frontend/src/app/findings/page.tsx @@ -0,0 +1,614 @@ +"use client" + +import { getApiUrl } from "@/config/api" +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { ArrowLeft, FileJson, FileText, FileCode, ShieldAlert, Globe, ChevronRight } from "lucide-react" +import { ProtectedRoute } from "@/components/protected-route" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" + +// API types mirror the backend finding schema. +interface Finding { + finding_id: string + title: string + category: string + severity: "critical" | "high" | "medium" | "low" | "info" + confidence: "high" | "medium" | "low" + detector?: string + affected_device?: string | null + source_ip?: string | null + destination_ip?: string | null + protocol?: string | null + port?: number | null + risk_description: string + recommendation: string + evidence: Record + related_packets: number[] + related_streams: number[] + timestamp_first_seen?: number | null + timestamp_last_seen?: number | null +} + +interface Summary { + total: number + by_severity: Record + by_category: Record + highlights: Finding[] + top: Finding[] +} + +interface AnalysisResult { + meta?: Record + summary: Summary + findings: Finding[] +} + +interface DeepStatus { + state: "pending" | "running" | "done" | "error" + url_total: number + url_done: number + hash_total: number + hash_done: number + cracked: number + progress_done: number + progress_total: number + percent: number + message?: string +} + +interface CloudUrl { + id: number + url: string + analyzed: boolean + ports?: number[] + tls_versions?: string[] + cipher_suites?: string[] +} + +interface CloudHash { + id: number + hash_value: string + analyzed: boolean + cracked: boolean + cracked_value?: string | null + crack_time?: string | null + hash_type?: string[] +} + +interface CloudResults { + urls: CloudUrl[] + hashes: CloudHash[] +} + +const SEV_ORDER = ["critical", "high", "medium", "low", "info"] as const + +const SEV_STYLE: Record = { + critical: "bg-red-600 text-white", + high: "bg-orange-500 text-white", + medium: "bg-yellow-400 text-black", + low: "bg-blue-400 text-white", + info: "bg-gray-400 text-white", +} + +const SEV_LABEL: Record = { + critical: "CRITICAL", + high: "HIGH", + medium: "MEDIUM", + low: "LOW", + info: "INFO", +} + +function Pill({ severity }: { severity: string }) { + return ( + + {SEV_LABEL[severity] || severity.toUpperCase()} + + ) +} + +function affectedLine(f: Finding): string { + const parts: string[] = [] + if (f.source_ip) parts.push(`Source ${f.source_ip}`) + if (f.destination_ip) parts.push(`Destination ${f.destination_ip}`) + if (f.protocol) parts.push(f.protocol + (f.port ? `:${f.port}` : "")) + const ev = f.evidence || {} + for (const k of ["host", "registered_domain", "query_name", "target"]) { + if (ev[k]) { parts.push(String(ev[k])); break } + } + return parts.join(", ") || "—" +} + +// Evidence rendered in dedicated sections is excluded from raw details. +const EVIDENCE_SKIP = new Set([ + "dedup_key", "sources", "artifact", "capture_file", "source_packet_count", "occurrences", +]) + +type SourceRef = { + packet?: number; stream?: number; timestamp?: number + src_ip?: string; dst_ip?: string; protocol?: string; port?: number + capture?: string; note?: string +} + +function fmtTs(ts?: number): string { + if (ts === undefined || ts === null) return "" + try { return new Date(ts * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC") } + catch { return String(ts) } +} + +function artifactOf(f: Finding): string | null { + const art = (f.evidence || {})["artifact"] as { label?: string; value?: string } | string | undefined + if (!art) return null + if (typeof art === "string") return art + if (art.value) return art.label ? `${art.label}: ${art.value}` : art.value + return null +} + +function sourcesOf(f: Finding): SourceRef[] { + const s = (f.evidence || {})["sources"] + return Array.isArray(s) ? (s as SourceRef[]) : [] +} + +function fmtSource(ref: SourceRef): string { + const parts: string[] = [] + if (ref.packet !== undefined) parts.push(`Packet #${ref.packet}`) + if (ref.stream !== undefined) parts.push(`Stream ${ref.stream}`) + if (ref.timestamp !== undefined) parts.push(fmtTs(ref.timestamp)) + if (ref.src_ip || ref.dst_ip) parts.push(`${ref.src_ip ?? "?"} → ${ref.dst_ip ?? "?"}`) + if (ref.protocol) parts.push(ref.protocol + (ref.port ? `:${ref.port}` : "")) + if (ref.capture) parts.push(`Capture ${ref.capture}`) + if (ref.note) parts.push(ref.note) + return parts.join(" · ") || "—" +} + +function FindingsContent() { + const router = useRouter() + const searchParams = useSearchParams() + const fileId = searchParams.get("fileId") + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [status, setStatus] = useState(null) + const [cloud, setCloud] = useState(null) + const [analyzing, setAnalyzing] = useState(false) + const [selUrls, setSelUrls] = useState>(new Set()) + const [selHashes, setSelHashes] = useState>(new Set()) + + const fetchCloud = async () => { + if (!fileId) return + try { + const res = await fetch(getApiUrl(`get_cloud_results/${fileId}`), { + cache: "no-store", + credentials: "include", + }) + if (res.ok) setCloud(await res.json()) + } catch { /* Optional enrichment data does not block the finding view. */ } + } + + useEffect(() => { + if (!fileId) { setError("No capture ID was provided."); setLoading(false); return } + const load = async () => { + try { + const res = await fetch(getApiUrl(`get_findings/${fileId}`), { + cache: "no-store", + credentials: "include", + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setData(await res.json()) + } catch (e) { + setError(`Findings could not be loaded: ${e instanceof Error ? e.message : String(e)}`) + } finally { + setLoading(false) + } + await fetchCloud() + try { + const sres = await fetch(getApiUrl(`get_analysis_status/${fileId}`), { + cache: "no-store", + credentials: "include", + }) + if (sres.ok) { + const s: DeepStatus = await sres.json() + setStatus(s) + if (s.state === "running") setAnalyzing(true) + } + } catch { /* Status is optional until enrichment starts. */ } + } + load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileId]) + + // Refresh enrichment results after the user returns to this tab. + useEffect(() => { + if (!fileId) return + const onFocus = () => { fetchCloud() } + window.addEventListener("focus", onFocus) + document.addEventListener("visibilitychange", onFocus) + return () => { + window.removeEventListener("focus", onFocus) + document.removeEventListener("visibilitychange", onFocus) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileId]) + + // Poll only while enrichment is running. + useEffect(() => { + if (!fileId || !analyzing) return + let timer: ReturnType | null = null + const poll = async () => { + try { + const res = await fetch(getApiUrl(`get_analysis_status/${fileId}`), { + cache: "no-store", + credentials: "include", + }) + if (res.ok) { + const s: DeepStatus = await res.json() + setStatus(s) + if (s.state === "done" || s.state === "error") setAnalyzing(false) + } + } catch { /* Retry on the next polling interval. */ } + await fetchCloud() + } + poll() + timer = setInterval(poll, 3000) + return () => { if (timer) clearInterval(timer) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileId, analyzing]) + + const startAnalysis = async () => { + if (!fileId || (selUrls.size + selHashes.size) === 0) return + try { + const response = await fetch(getApiUrl(`start_analysis/${fileId}`), { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }), + cache: "no-store", + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + setAnalyzing(true) + } catch (e) { + setError(`Enrichment could not be started: ${e instanceof Error ? e.message : String(e)}`) + } + } + + const toggleId = (set: Set, setter: (s: Set) => void, id: number) => { + const next = new Set(set) + if (next.has(id)) next.delete(id); else next.add(id) + setter(next) + } + const setAll = (ids: number[], setter: (s: Set) => void, on: boolean) => + setter(on ? new Set(ids) : new Set()) + + const download = (fmt: "md" | "txt" | "json") => { + if (!fileId) return + window.open(getApiUrl(`get_report/${fileId}/${fmt}`), "_blank") + } + + if (loading) { + return + } + if (error) { + return + } + + const summary = data?.summary + const findings = data?.findings ?? [] + const isEmpty = findings.length === 0 + + return ( +
+
+ + +
+ +
+ + + + +
+
+ + Security findings + + + Network-capture evaluation by IoT PreTester 4.6 Demo. + +
+ + {/* Status of optional URL and hash enrichment. */} + {status && status.state !== "pending" && ( +
+
+ + {status.state === "running" && "Artifact enrichment is running…"} + {status.state === "done" && "✅ Artifact enrichment completed"} + {status.state === "error" && "⚠️ Artifact enrichment failed"} + + + URLs {status.url_done}/{status.url_total} · Hashes {status.hash_done}/{status.hash_total} + {status.cracked > 0 && ` · ${status.cracked} cracked`} + +
+ {status.state === "running" && status.progress_total > 0 && ( +
+
+
+ )} + {(status.state === "done" || status.state === "running") && ( +

+ Details are available under “Cloud URLs and hashes”. +

+ )} +
+ )} + + {/* Severity summary */} + {summary && ( +
+ {SEV_ORDER.map((sev) => ( +
+ + + {summary.by_severity?.[sev] ?? 0} + +
+ ))} +
+ Total: {summary.total} +
+
+ )} + + {/* Prioritized highlights */} + {summary && summary.highlights?.length > 0 && ( +
+

⚠️ Prioritized highlights

+
    + {summary.highlights.map((f) => ( +
  • + {f.title} + — {affectedLine(f)} +
  • + ))} +
+
+ )} + + {isEmpty ? ( +
+ No security-relevant findings were detected, or analysis is still running. +
Reload the page if the capture has just been submitted. +
+ ) : ( +
+ {findings.map((f, i) => { + const artifact = artifactOf(f) + const sources = sourcesOf(f) + const pktTotal = typeof f.evidence?.source_packet_count === "number" + ? (f.evidence.source_packet_count as number) : 0 + const captureFile = typeof f.evidence?.capture_file === "string" + ? (f.evidence.capture_file as string) : null + return ( + + +
+

{i + 1}. {f.title}

+
+ + Confidence: {f.confidence} +
+
+ {/* Primary artifact */} + {artifact && ( +
+
🔎 Observed artifact
+ {artifact} +
+ )} + + {/* Packet-level sources */} + {sources.length > 0 && ( +
+
📍 Capture sources
+
    + {sources.map((ref, idx) => ( +
  • + {fmtSource(ref)} +
  • + ))} + {pktTotal > sources.length && ( +
  • + … and {pktTotal - sources.length} more packets +
  • + )} +
+
+ )} + +

Risk: {f.risk_description}

+

Affected: {affectedLine(f)}

+

Recommendation: {f.recommendation}

+
+ Category: {f.category} · Detector: {f.detector} + {captureFile && <> · Capture: {captureFile}} +
+ {f.evidence && Object.keys(f.evidence).filter((k) => !EVIDENCE_SKIP.has(k)).length > 0 && ( +
+ Additional evidence +
+                            {JSON.stringify(
+                              Object.fromEntries(Object.entries(f.evidence).filter(([k]) => !EVIDENCE_SKIP.has(k))),
+                              null, 2)}
+                          
+
+ )} +
+
+ ) + })} +
+ )} + + {cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && ( +

Extracted artifacts

+ )} + + {/* Hash artifacts */} + {cloud && cloud.hashes.length > 0 && ( +
+ +

+ + Hashes ({cloud.hashes.length}) +

+ +
+
+ {cloud.hashes.map((h) => ( + + +
+

+ toggleId(selHashes, setSelHashes, h.id)} /> + {h.hash_value} +

+
+ {h.cracked + ? CRACKED + : h.analyzed + ? not cracked + : (analyzing && selHashes.has(h.id)) + ? running… + : not analyzed} +
+
+ {h.hash_type && h.hash_type.length > 0 && ( +

Type: {h.hash_type.join(", ")}

+ )} + {h.cracked && ( +

+ Plaintext:{" "} + {h.cracked_value} + {h.crack_time ? ` (${h.crack_time})` : ""} +

+ )} +
+
+ ))} +
+
+ )} + + {/* Endpoint artifacts */} + {cloud && cloud.urls.length > 0 && ( +
+ +

+ + Cloud URLs and endpoints ({cloud.urls.length}) +

+ +
+
+ {cloud.urls.map((u) => ( + + +
+

+ toggleId(selUrls, setSelUrls, u.id)} /> + {u.url} +

+
+ {u.analyzed + ? analyzed + : (analyzing && selUrls.has(u.id)) + ? running… + : not analyzed} +
+
+ {u.analyzed && ( + <> +

Open ports: {u.ports && u.ports.length > 0 ? u.ports.join(", ") : "—"}

+ {u.tls_versions && u.tls_versions.length > 0 &&

TLS: {u.tls_versions.join(", ")}

} + {u.cipher_suites && u.cipher_suites.length > 0 &&

Cipher: {u.cipher_suites.join(", ")}

} + + )} +
+
+ ))} +
+
+ )} + + +
+
+ ) +} + +function sevBorder(sev: string): string { + return ({ + critical: "#dc2626", high: "#f97316", medium: "#facc15", low: "#60a5fa", info: "#9ca3af", + } as Record)[sev] || "#9ca3af" +} + +function CenterCard({ title, desc }: { title: string; desc: string }) { + return ( +
+ + + {title} + {desc} + + +
+ ) +} + +export default function FindingsPage() { + return ( + + }> + + + + ) +} diff --git a/frontend/src/app/fonts/GeistVF.woff b/frontend/src/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/frontend/src/app/fonts/GeistVF.woff differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..155cffb --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 210 40% 98%; + --foreground: 222.2 47.4% 11.2%; + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + --primary: 221 83% 53%; + --primary-foreground: 0 0% 100%; + --secondary: 214 32% 91%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 217 91% 95%; + --accent-foreground: 221 83% 45%; + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 100%; + --border: 214.3 31.8% 88%; + --input: 214.3 31.8% 86%; + --ring: 221 83% 53%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3%; + --foreground: 0 0% 0%; + --card: 0 0% 5%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..a1f0eae --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,25 @@ +import "./globals.css" +import localFont from "next/font/local" +import { Providers } from "@/components/providers" +import { Toaster } from "@/components/ui/toaster" + +const geist = localFont({ src: "./fonts/GeistVF.woff" }) + +export const metadata = { + title: "IoT PreTester 4.6 Demo", + description: "Partner demo for IoT network-capture security analysis", +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + +
+ 4.6 Demo +
+ + + ) +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..f415b7c --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,114 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/auth-context" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { AnimatedBackground } from "@/components/ui/animated-background" +import Image from "next/image" + +const LoginPage = () => { + const router = useRouter() + const { login } = useAuth() + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setError(null) + setIsLoading(true) + + try { + const response = await fetch(getApiUrl("login"), { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + }) + + const [success, data] = await response.json() + + if (success) { + login(data.user) + router.push("/devices") + } else { + setError(data.message || "Invalid username or password") + } + } catch { + setError("An error occurred during login") + } finally { + setIsLoading(false) + } + } + + return ( +
+ +
+ IoT PreTester 4.6 Demo logo +
+ + + Login + Enter your credentials to access the system + + +
+
+ + setUsername(e.target.value)} + required + placeholder="Enter your username" + className="bg-background/50 border-primary/20 focus:border-primary" + /> +
+
+ + setPassword(e.target.value)} + required + placeholder="Enter your password" + className="bg-background/50 border-primary/20 focus:border-primary" + /> +
+ + {error && ( + + {error} + + )} + + +
+
+
+

4.6 Demo

+
+ ) +} + +export default LoginPage diff --git a/frontend/src/app/manage-device-types/page.tsx b/frontend/src/app/manage-device-types/page.tsx new file mode 100644 index 0000000..3b182aa --- /dev/null +++ b/frontend/src/app/manage-device-types/page.tsx @@ -0,0 +1,184 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { ProtectedRoute } from "@/components/protected-route" +import { useAuth } from "@/lib/auth-context" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" +import axios from "axios" + +interface DeviceType { + value: number + label: string +} + +export default function ManageDeviceTypesPage() { + const router = useRouter() + const { user } = useAuth() + const [deviceTypes, setDeviceTypes] = useState([]) + const [newTypeLabel, setNewTypeLabel] = useState("") + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [typeToDelete, setTypeToDelete] = useState(null) + + useEffect(() => { + if (user?.role !== "admin" && user?.role !== "superuser") { + router.push("/") + } + }, [user, router]) + + const fetchDeviceTypes = async () => { + try { + const response = await axios.get(getApiUrl("get_all_devicetypes")) + const formattedDeviceTypes = response.data.map((item: { id: number; device_type: string }) => ({ + value: item.id, + label: item.device_type, + })) + setDeviceTypes(formattedDeviceTypes) + } catch (error) { + console.error("Error fetching device types:", error) + } + } + + useEffect(() => { + fetchDeviceTypes() + }, []) + + const addDeviceType = async () => { + if (newTypeLabel.trim()) { + try { + await axios.post(getApiUrl("create_device_type"), { device_type: newTypeLabel.trim() }) + setNewTypeLabel("") + fetchDeviceTypes() + } catch (error) { + console.error("Error adding device type:", error) + } + } + } + + const handleDeleteClick = (value: number) => { + setTypeToDelete(value) + setDeleteDialogOpen(true) + } + + const confirmDelete = async () => { + if (typeToDelete !== null) { + try { + await axios.delete(getApiUrl(`delete_device_type/${typeToDelete}`)) + fetchDeviceTypes() + setDeleteDialogOpen(false) + setTypeToDelete(null) + } catch (error) { + console.error("Error deleting device type:", error) + } + } + } + + return ( + +
+
+ + + Manage Device Types + + Add or remove device types that users can select when adding new devices + + + +
+
+
+
+ + setNewTypeLabel(e.target.value)} + placeholder="Enter device type name" + /> +
+
+ +
+
+
+ +
+

Available Device Types

+ + + + Display Name + Value + Actions + + + + {deviceTypes.map((type) => ( + + {type.label} + {type.value} + + + + + ))} + +
+
+ +
+ +
+
+
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Device Type + + Are you sure you want to delete this device type? This action cannot be undone. + + + + + + + + +
+ ) +} diff --git a/frontend/src/app/manage-manufacturers/page.tsx b/frontend/src/app/manage-manufacturers/page.tsx new file mode 100644 index 0000000..8e1d441 --- /dev/null +++ b/frontend/src/app/manage-manufacturers/page.tsx @@ -0,0 +1,184 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { ProtectedRoute } from "@/components/protected-route" +import { useAuth } from "@/lib/auth-context" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" +import axios from "axios" + +interface Manufacturer { + value: number + label: string +} + +export default function ManageManufacturersPage() { + const router = useRouter() + const { user } = useAuth() + const [manufacturers, setManufacturers] = useState([]) + const [newManufacturerLabel, setNewManufacturerLabel] = useState("") + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [manufacturerToDelete, setManufacturerToDelete] = useState(null) + + useEffect(() => { + if (user?.role !== "superuser" && user?.role !== "admin") { + router.push("/") + } + }, [user, router]) + + const fetchManufacturers = async () => { + try { + const response = await axios.get(getApiUrl("get_all_manufacturers")) + const formattedManufacturers = response.data.map((item: { id: number; name: string }) => ({ + value: item.id, + label: item.name, + })) + setManufacturers(formattedManufacturers) + } catch (error) { + console.error("Error fetching manufacturers:", error) + } + } + + useEffect(() => { + fetchManufacturers() + }, []) + + const addManufacturer = async () => { + if (newManufacturerLabel.trim()) { + try { + await axios.post(getApiUrl("create_manufact"), { name: newManufacturerLabel.trim() }) + setNewManufacturerLabel("") + fetchManufacturers() + } catch (error) { + console.error("Error adding manufacturer:", error) + } + } + } + + const handleDeleteClick = (value: number) => { + setManufacturerToDelete(value) + setDeleteDialogOpen(true) + } + + const confirmDelete = async () => { + if (manufacturerToDelete !== null) { + try { + await axios.delete(getApiUrl(`delete_manufacturer/${manufacturerToDelete}`)) + fetchManufacturers() + setDeleteDialogOpen(false) + setManufacturerToDelete(null) + } catch (error) { + console.error("Error deleting manufacturer:", error) + } + } + } + + return ( + +
+
+ + + Manage Manufacturers + + Add or remove manufacturers that users can select when adding new devices + + + +
+
+
+
+ + setNewManufacturerLabel(e.target.value)} + placeholder="Enter manufacturer name" + /> +
+
+ +
+
+
+ +
+

Available Manufacturers

+ + + + Display Name + Value + Actions + + + + {manufacturers.map((manufacturer) => ( + + {manufacturer.label} + {manufacturer.value} + + + + + ))} + +
+
+ +
+ +
+
+
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Manufacturer + + Are you sure you want to delete this manufacturer? This action cannot be undone. + + + + + + + + +
+ ) +} diff --git a/frontend/src/app/manage-users/page.tsx b/frontend/src/app/manage-users/page.tsx new file mode 100644 index 0000000..245c74f --- /dev/null +++ b/frontend/src/app/manage-users/page.tsx @@ -0,0 +1,390 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import axios from "axios" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { ProtectedRoute } from "@/components/protected-route" +import { useAuth } from "@/lib/auth-context" +import { ArrowLeft, Copy } from "lucide-react" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { useToast } from "@/components/ui/use-toast" + +interface User { + id: number + username: string + role: string +} + +function generatePassword() { + const length = 12 + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + const values = crypto.getRandomValues(new Uint32Array(length)) + return Array.from(values, (value) => charset[value % charset.length]).join("") +} + +export default function ManageUsersPage() { + const router = useRouter() + const { user } = useAuth() + const [users, setUsers] = useState([]) + const [newUsername, setNewUsername] = useState("") + const [newRole, setNewRole] = useState("user") + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [userToDelete, setUserToDelete] = useState(null) + const [generatedPassword, setGeneratedPassword] = useState(null) + const [showPasswordAlert, setShowPasswordAlert] = useState(false) + const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false) + const [userToResetPassword, setUserToResetPassword] = useState(null) + const { toast } = useToast() + + useEffect(() => { + if (user?.role !== "admin" && user?.role !== "superuser") { + router.push("/") + } + }, [user, router]) + + useEffect(() => { + const fetchUsers = async () => { + try { + const response = await axios.get(getApiUrl("list-users")) + if (response.status === 200 && response.data) { + setUsers(response.data.data) + } else { + console.error("Failed to fetch users") + } + } catch (error) { + console.error("Error fetching users:", error) + } + } + + fetchUsers() + }, []) + + const addUser = async () => { + if (newUsername) { + const password = generatePassword() + try { + const response = await axios.post(getApiUrl("register"), { + username: newUsername, + role: newRole, + password: password, + }) + if (response.data.status === "success") { + const fetchUsers = async () => { + try { + const response = await axios.get(getApiUrl("list-users")) + if (response.status === 200 && response.data) { + setUsers(response.data.data) + } else { + console.error("Failed to fetch users") + } + } catch (error) { + console.error("Error fetching users:", error) + } + } + await fetchUsers() + setNewUsername("") + setNewRole("user") + setGeneratedPassword(password) + setShowPasswordAlert(true) + toast({ + title: "Success", + description: "User created successfully", + duration: 2000, + }) + } else { + console.error("Failed to create user") + toast({ + title: "Error", + description: "Failed to create user", + variant: "destructive", + duration: 2000, + }) + } + } catch (error) { + console.error("Error creating user:", error) + toast({ + title: "Error", + description: "An error occurred while creating the user", + variant: "destructive", + duration: 2000, + }) + } + } + setTimeout(() => setShowPasswordAlert(false), 30000) + } + + const handleDeleteClick = (id: number) => { + setUserToDelete(id) + setDeleteDialogOpen(true) + } + + const confirmDelete = async () => { + if (userToDelete) { + try { + const response = await axios.post(getApiUrl("delete-user"), { id: userToDelete }) + if (response.data.status === "success") { + setUsers(users.filter((user) => user.id !== userToDelete)) + setDeleteDialogOpen(false) + setUserToDelete(null) + toast({ + title: "Success", + description: "User deleted successfully", + duration: 2000, + }) + } else { + console.error("Failed to delete user") + toast({ + title: "Error", + description: "Failed to delete user", + variant: "destructive", + duration: 2000, + }) + } + } catch (error) { + console.error("Error deleting user:", error) + toast({ + title: "Error", + description: "An error occurred while deleting the user", + variant: "destructive", + duration: 2000, + }) + } + } + } + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast({ + title: "Copied!", + description: "Password copied to clipboard", + duration: 2000, + }) + } catch (err) { + console.error("Failed to copy text: ", err) + toast({ + title: "Error", + description: "Failed to copy password", + variant: "destructive", + duration: 2000, + }) + } + } + + const handleResetPasswordClick = (userId: number) => { + setUserToResetPassword(userId) + setResetPasswordDialogOpen(true) + } + + const confirmResetPassword = async () => { + if (userToResetPassword) { + const newPassword = generatePassword() + try { + const response = await axios.post(getApiUrl("reset-password"), { + user_id: userToResetPassword, + new_password: newPassword, + }) + if (response.data.status === "success") { + const fetchUsers = async () => { + try { + const response = await axios.get(getApiUrl("list-users")) + if (response.status === 200 && response.data) { + setUsers(response.data.data) + } else { + console.error("Failed to fetch users") + } + } catch (error) { + console.error("Error fetching users:", error) + } + } + await fetchUsers() + setGeneratedPassword(newPassword) + setShowPasswordAlert(true) + setResetPasswordDialogOpen(false) + setUserToResetPassword(null) + toast({ + title: "Success", + description: "Password reset successfully", + duration: 2000, + }) + } else { + console.error("Failed to reset password") + toast({ + title: "Error", + description: "Failed to reset password", + variant: "destructive", + duration: 2000, + }) + } + } catch (error) { + console.error("Error resetting password:", error) + toast({ + title: "Error", + description: "An error occurred while resetting the password", + variant: "destructive", + duration: 2000, + }) + } + } + setTimeout(() => setShowPasswordAlert(false), 30000) + } + + return ( + +
+
+ + + Manage Users + Add, view, or remove users from the system + + +
+
+

Add New User

+
+
+ + setNewUsername(e.target.value)} + placeholder="Enter username" + /> +
+
+ + +
+
+ +
+
+
+ + {showPasswordAlert && generatedPassword && ( + + +
+ Initial password: + {generatedPassword} +
+ +
+
+ )} + +
+

Existing Users

+ + + + ID + Username + Role + Actions + + + + {users.map((user) => ( + + {user.id} + {user.username} + {user.role} + +
+ + +
+
+
+ ))} +
+
+
+ +
+ +
+
+
+
+
+
+ + + + + Delete User + + Are you sure you want to delete this user? This action cannot be undone. + + + + + + + + + + {/* Reset Password Confirmation Dialog */} + + + + Reset Password + + Are you sure you want to reset this user's password? Share the new initial password securely. + + + + + + + + +
+ ) +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..ad53219 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function Home() { + redirect('/devices') +} \ No newline at end of file diff --git a/frontend/src/app/scan-results/page.tsx b/frontend/src/app/scan-results/page.tsx new file mode 100644 index 0000000..3bc0d0d --- /dev/null +++ b/frontend/src/app/scan-results/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" + + +// The legacy route forwards directly to the consolidated findings view. +const ROUTE_FINDINGS = "/findings" + +function SmartResultsRedirectContent() { + const router = useRouter() + const searchParams = useSearchParams() + const [status, setStatus] = useState<"idle" | "checking" | "redirected" | "error">("idle") + + useEffect(() => { + const fileId = searchParams.get("fileId") + if (!fileId) { + setStatus("error") + return + } + setStatus("checking") + router.replace(`${ROUTE_FINDINGS}?fileId=${encodeURIComponent(fileId)}`) + setStatus("redirected") + }, [router, searchParams]) + + return ( + +
+ + + + {status === "error" ? "Error" : "Loading..."} + + + {status === "error" + ? "Couldn't determine result page. Please try again." + : "Determining the correct result page…"} + + + +
+
+ ) +} + +export default function SmartResultsRedirectPage() { + return ( + +
+ + + Loading... + Please wait... + + +
+ + }> + +
+ ) +} diff --git a/frontend/src/components/device-images.ts b/frontend/src/components/device-images.ts new file mode 100644 index 0000000..044c2e3 --- /dev/null +++ b/frontend/src/components/device-images.ts @@ -0,0 +1,23 @@ +export const getDeviceImage = (deviceType: string) => { + switch (deviceType.toLowerCase()) { + case 'camera': + return '/images/devices/camera.png'; + case 'smart-plug': + return '/images/devices/smart_plug.png'; + case 'smart-bulb': + return '/images/devices/smart_bulb.png'; + case 'smart-switch': + return '/images/devices/smart_switch.png'; + case 'robot-vacuum': + case 'robot vacuum cleaner': + return '/images/devices/robot_vacuum_cleaner.png'; + case 'smart-lock': + return '/images/devices/smart_lock.png'; + case 'smart-controller': + return '/images/devices/smart_controller.png'; + case 'sensor': + return '/images/devices/sensor.png'; + default: + return '/images/logos/Placeholder.png'; + } +}; \ No newline at end of file diff --git a/frontend/src/components/file-upload.tsx b/frontend/src/components/file-upload.tsx new file mode 100644 index 0000000..dfb84ce --- /dev/null +++ b/frontend/src/components/file-upload.tsx @@ -0,0 +1,237 @@ +"use client" +import { getApiUrl } from "@/config/api" + +import * as React from "react" +import { useState } from "react" +import { useRouter } from "next/navigation" +import axios from "axios" +import { FileIcon, UploadCloudIcon, XIcon, ScanIcon, ArrowLeft } from "lucide-react" +import { useDropzone } from "react-dropzone" +import type { FileRejection } from "react-dropzone" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface FileUploadProps { + onBack?: () => void + deviceId?: string +} + +interface FileDetails { + firmware: string + ipAddress: string +} + +export function FileUpload({ onBack, deviceId }: FileUploadProps) { + const router = useRouter() + const [file, setFile] = useState(null) + const [error, setError] = useState(null) + const [uploadProgress, setUploadProgress] = useState(0) + const [isScanning, setIsScanning] = useState(false) + const [fileDetails, setFileDetails] = useState({ + firmware: "", + ipAddress: "", + }) + + const onDrop = React.useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => { + if (fileRejections.length > 0) { + setError("Invalid file type. Please upload a .pcap or .pcapng capture.") + return + } + + if (acceptedFiles.length > 0) { + const selectedFile = acceptedFiles[0] + if (selectedFile.size > 512 * 1024 * 1024) { + setError("File size exceeds the 512 MB demo limit.") + return + } + setFile(selectedFile) + setError(null) + setUploadProgress(0) + } + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + "application/vnd.tcpdump.pcap": [".pcap"], + "application/x-pcapng": [".pcapng"], + }, + maxFiles: 1, + multiple: false, + }) + + const removeFile = () => { + setFile(null) + setUploadProgress(0) + setError(null) + setFileDetails({ + firmware: "", + ipAddress: "", + }) + } + + const handleScan = async () => { + if (!file || !fileDetails.firmware || !fileDetails.ipAddress) return + + try { + setIsScanning(true) + setError(null) + + const formData = new FormData() + formData.append("capture", file) + formData.append("device_id", deviceId || "") + formData.append("firmware_version", fileDetails.firmware) + formData.append("ip_address", fileDetails.ipAddress) + + const response = await axios.post(getApiUrl("captures"), formData, { + onUploadProgress: (event) => { + if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100)) + }, + }) + + const fileId = response.data.data.id + router.push(`/scan-results?fileId=${fileId}`) + } catch (err) { + console.error("Scan error:", err) + const apiError = axios.isAxiosError(err) + ? err.response?.data?.error || err.response?.data?.capture?.[0] || err.message + : err instanceof Error + ? err.message + : String(err) + setError(`Failed to scan file: ${apiError}`) + } finally { + setIsScanning(false) + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFileDetails((prev) => ({ + ...prev, + [name]: value, + })) + } + + return ( + + +
+ {onBack && ( + + )} +
+
+ Upload New File + + Upload a PCAP/PCAPNG capture for IoT network security analysis. + +
+
+ + {!file && ( +
+ +
+ +
+

Drag & drop your file here

+

+ or click to select a file from your computer +

+

+ Supported formats: .pcap and .pcapng · maximum 512 MB +

+
+
+
+ )} + + {error && ( + + Error + {error} + + )} + + {file && ( +
+
+
+
+ +
+ {file.name} + + Network capture + +
+
+ +
+ {isScanning && } +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/protected-route.tsx b/frontend/src/components/protected-route.tsx new file mode 100644 index 0000000..96ef44e --- /dev/null +++ b/frontend/src/components/protected-route.tsx @@ -0,0 +1,21 @@ +"use client" + +import { ReactNode, useEffect } from "react" +import { useRouter } from "next/navigation" +import { AuthUser, useAuth } from "@/lib/auth-context" + +interface ProtectedRouteProps { + children: ReactNode | ((props: { user: AuthUser; logout: () => void }) => ReactNode) +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user, logout } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!user) router.replace("/login") + }, [user, router]) + + if (!user) return null + return typeof children === "function" ? children({ user, logout }) : children +} diff --git a/frontend/src/components/providers.tsx b/frontend/src/components/providers.tsx new file mode 100644 index 0000000..8d96851 --- /dev/null +++ b/frontend/src/components/providers.tsx @@ -0,0 +1,8 @@ +'use client' + +import { AuthProvider } from '@/lib/auth-context' + +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} + diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/src/components/ui/animated-background.tsx b/frontend/src/components/ui/animated-background.tsx new file mode 100644 index 0000000..0de9dc5 --- /dev/null +++ b/frontend/src/components/ui/animated-background.tsx @@ -0,0 +1,37 @@ +"use client" + +export function AnimatedBackground() { + return ( + <> + {/* Floating particles */} +
+ {[...Array(20)].map((_, i) => ( +
+ ))} +
+ + {/* CSS Animations */} + + + ) +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..7626834 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-primary/40", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..185ebf8 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..01ff19c --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..68551b9 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..5c87ea4 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..cbe5a36 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..7f3502f --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 0000000..72915c4 --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,126 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +export type ToastProps = React.ComponentPropsWithoutRef +export type ToastActionElement = React.ReactElement + +export { + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 0000000..17fdb54 --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ + "use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/use-toast.ts b/frontend/src/components/ui/use-toast.ts new file mode 100644 index 0000000..f211abe --- /dev/null +++ b/frontend/src/components/ui/use-toast.ts @@ -0,0 +1,162 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" +import { useState } from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = "ADD_TOAST" | "UPDATE_TOAST" | "DISMISS_TOAST" | "REMOVE_TOAST" + +type Action = + | { + type: Extract + toast: ToasterToast + } + | { + type: Extract + toast: Partial + } + | { + type: Extract + toastId?: ToasterToast["id"] + } + | { + type: Extract + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +export function useToast() { + const [toasts, setToasts] = useState([]) + + return { + toasts, + toast: (props: Omit) => { + const id = genId() + setToasts((toasts) => [ + ...toasts, + { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) { + setToasts((toasts) => toasts.filter((t) => t.id !== id)) + } + }, + }, + ]) + }, + dismiss: (id: string) => { + setToasts((toasts) => toasts.filter((t) => t.id !== id)) + } + } +} diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts new file mode 100644 index 0000000..fc2148e --- /dev/null +++ b/frontend/src/config/api.ts @@ -0,0 +1,11 @@ +import axios from "axios" + +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api" + +// The Django API uses an authenticated session shared across localhost ports. +axios.defaults.withCredentials = true + +export const getApiUrl = (endpoint: string): string => { + const path = endpoint.replace(/^\/+/, "") + return `${API_BASE_URL.replace(/\/+$/, "")}/${path}` +} diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx new file mode 100644 index 0000000..7585eab --- /dev/null +++ b/frontend/src/lib/auth-context.tsx @@ -0,0 +1,49 @@ +import { createContext, useContext, useEffect, useState } from "react" + +export type AuthUser = { + id: number + username: string + role: string +} + +type AuthContextType = { + user: AuthUser | null + login: (user: AuthUser) => void + logout: () => void +} + +const AuthContext = createContext(undefined) + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [user, setUser] = useState(null) + + useEffect(() => { + const storedUser = localStorage.getItem("user") + if (!storedUser) return + try { + setUser(JSON.parse(storedUser)) + } catch { + localStorage.removeItem("user") + } + }, []) + + const login = (userData: AuthUser) => { + setUser(userData) + localStorage.setItem("user", JSON.stringify(userData)) + } + + const logout = () => { + setUser(null) + localStorage.removeItem("user") + } + + return {children} +} + +export const useAuth = () => { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider") + } + return context +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bd0b3ae --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + diff --git a/frontend/src/services/apiIOT.ts b/frontend/src/services/apiIOT.ts new file mode 100644 index 0000000..97129b4 --- /dev/null +++ b/frontend/src/services/apiIOT.ts @@ -0,0 +1,115 @@ +import axios from "axios" +import type { Device } from "@/types/device" +import { getApiUrl } from "@/config/api" + +interface ApiDevice { + id: number + device_name: string + device_type: string + manufacturer_name: string + serial: string + comment: string + created: string +} + +interface ApiCapture { + id: number + filename: string + upload: string + firmware_version: string + ip: string +} + +interface ApiManufacturer { + id: number + name: string +} + +interface ApiDeviceType { + id: number + device_type: string +} + +export const getAllDevices = async (): Promise => { + try { + const response = await axios.get(getApiUrl("get_all_devices")) + const devices = await Promise.all( + response.data.map(async (device: ApiDevice) => { + const filesResponse = await axios.get(getApiUrl(`get_file_by_device/${device.id}`)) + const filesData: ApiCapture[] = filesResponse.data + return { + id: String(device.id), + name: device.device_name, + type: device.device_type, + manufacturer: device.manufacturer_name, + serialNumber: device.serial, + comment: device.comment, + ipAddress: "", + lastScanned: new Date(device.created).toLocaleDateString("en-GB"), + files: filesData.map((file) => ({ + id: String(file.id), + name: file.filename, + uploadedAt: file.upload, + status: "scanned" as const, + firmwareVersion: file.firmware_version, + ipAddress: file.ip, + })), + } + }), + ) + return devices + } catch (error) { + console.error("Failed to fetch devices:", error) + return [] + } +} + +export const getManufacturers = async () => { + try { + const response = await axios.get(getApiUrl("get_all_manufacturers")) + return response.data.map((type: ApiManufacturer) => ({ + value: String(type.id), + label: type.name, + })) + } catch (error) { + console.error("Failed to fetch manufacturers:", error) + return [] + } +} + +export const getDeviceTypes = async () => { + try { + const response = await axios.get(getApiUrl("get_all_devicetypes")) + return response.data.map((type: ApiDeviceType) => ({ + value: String(type.id), + label: type.device_type, + })) + } catch (error) { + console.error("Failed to fetch device types:", error) + return [] + } +} + +export const addDevice = async (newDevice: { + device_name: string + device_type_id: string + manufacturer_id: string + serial: string + comment: string +}) => { + try { + await axios.post(getApiUrl("create_device"), newDevice) + } catch (error) { + console.error("Failed to add device:", error) + throw error + } +} + +export const deleteDevice = async (deviceId: string) => { + try { + await axios.delete(getApiUrl(`delete_device/${deviceId}`)) + } catch (error) { + console.error("Failed to delete device:", error) + throw error + } +} diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts new file mode 100644 index 0000000..f92aa23 --- /dev/null +++ b/frontend/src/types/device.ts @@ -0,0 +1,20 @@ +export interface Device { + id: string + name: string + type: string + manufacturer: string + serialNumber: string + ipAddress?: string + comment?: string + lastScanned?: string + files?: DeviceFile[] +} + +export interface DeviceFile { + id: string + name: string + uploadedAt: string + status: "scanned" | "pending" + firmwareVersion?: string + ipAddress?: string +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..3175604 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,63 @@ +import type { Config } from "tailwindcss"; +import animate from "tailwindcss-animate"; + +export default { + darkMode: ["class"], + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + } + }, + plugins: [animate], +} satisfies Config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a15125f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# Direct runtime dependencies for IoT PreTester 4.6 Demo +bcrypt==4.2.1 +Django==4.2.20 +django-cors-headers==4.6.0 +djangorestframework==3.15.2 +dnspython==2.7.0 +gunicorn==23.0.0 +pyshark==0.6 +python-nmap==0.7.1