23 Commits

Author SHA1 Message Date
dextmorgn
82925bec38 fix(deploy): serve prod from pre-built images
Frontend baked VITE_API_URL into the bundle at build time, so
published images pointed at whatever URL CI had — "fail to fetch"
for every other deployment. Bundle now defaults to same-origin
relative URLs and the frontend nginx proxies /api/ to the api
service over the Docker network; CORS becomes irrelevant.

- docker-compose.prod.yml: use ghcr images (FLOWSINT_VERSION,
  default latest), drop build sections; bind postgres/redis/neo4j/
  api ports to 127.0.0.1 — only 5173 faces the network
- nginx: /api/ reverse proxy, request-time DNS resolution, SSE
  support (proxy_buffering off)
- Makefile: prod pulls instead of building; remove dead deploy
  targets referencing nonexistent docker-compose.deploy.yml
- README: network/server deployment section (secrets checklist,
  version pinning, TLS via reverse proxy)
2026-06-05 14:04:16 +02:00
dextmorgn
f38d5d500d Merge pull request #174 from reconurge/chore/security-dependency-upgrades
fix(deps): upgrade Python dependencies to patch 27 known vulnerabilities
2026-06-05 13:21:03 +02:00
dextmorgn
9fcc60ca63 fix(deps): upgrade Python dependencies to patch 27 known vulnerabilities
pip-audit reported 27 vulnerabilities across 12 packages, including
cryptography PYSEC-2026-36 (CVSS 9.8) used by the vault, a starlette
Host-header path bypass, and python-multipart arbitrary file write.

- cryptography 45.0.7 -> 46.0.7 (pin was capped at <46)
- fastapi 0.115 -> 0.136, pulling starlette 0.46 -> 1.2
- python-multipart 0.0.20 -> 0.0.32
- pyjwt, urllib3, aiohttp, lxml, idna, mako and transitive bumps
- dev tools: pytest 8 -> 9 (requires pytest-httpx >=0.36), black 25 -> 26

All four module test suites pass (458 tests). pip-audit now reports
no known vulnerabilities.
2026-06-05 08:57:19 +02:00
dextmorgn
5cdfe727b4 Merge pull request #172 from melihdaskiran/fix/zip-and-vault-bugs
Fix zip alignment mismatch, phone validation return value, and vault …
2026-06-05 08:24:39 +02:00
Melih Daskiran
25eb3a0400 Fix zip alignment mismatch, phone validation return value, and vault owner_id type matching 2026-06-04 21:46:44 +03:00
dextmorgn
259019f23c fix(build): force LF on container files via .gitattributes
Windows checkouts with core.autocrlf=true convert entrypoint.sh to
CRLF, which Docker copies into the image. The shebang then reads
#!/bin/sh\r and exec fails with 'no such file or directory'.
Also covers .env.example (trailing \r in env values) and Makefile.
2026-06-04 18:23:53 +02:00
dextmorgn
7c082d733e docs(readme): make Windows install commands cmd-compatible
PowerShell foreach/Test-Path loop fails when pasted into cmd.exe.
Plain copy commands work in both shells.
2026-06-04 18:05:19 +02:00
dextmorgn
de93ae3059 docs(readme): add Windows install path without Make
Windows users hit failures with make-based install. Replicate
check-env + build + up targets as plain PowerShell/docker compose
commands for prod and dev.
2026-06-04 18:00:35 +02:00
dextmorgn
c6eaf08b66 Merge pull request #163 from Mubashirrrr/fix/json-import-apostrophe-corruption
fix(import): preserve apostrophes in valid JSON imports
2026-06-03 20:34:42 +02:00
dextmorgn
f380674955 Merge pull request #165 from reconurge/fix/sse-auth-header
fix(sse): auth header
2026-06-03 13:42:17 +02:00
Mubashir Rahim
1fdbdbd094 fix(import): preserve apostrophes in valid JSON imports
parse_json unconditionally replaced every single quote with a double
quote (`.replace("'", '"')`) before calling json.loads. This corrupted
any valid JSON string value containing an apostrophe, e.g. a person's
surname "Sarah O'Brien" became "Sarah O"Brien", causing the parse to
fail with "Invalid JSON" and the whole import to be rejected.

Parse strict JSON first so well-formed payloads (including apostrophes
in string values) import correctly, and only fall back to the lenient
single-quote replacement for Python-dict-style payloads that are not
valid JSON on their own.

Adds regression tests covering an apostrophe-bearing JSON value and the
retained single-quoted fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:34:22 +05:00
dextmorgn
b317e08e57 feat(api): authenticate SSE via header, drop query-param auth and dead route 2026-06-03 11:28:41 +02:00
dextmorgn
9db1e675d6 test(api): SSE auth-gate and endpoint-removal tests (red) 2026-06-03 11:22:58 +02:00
dextmorgn
4a75d6a924 refactor(app): stream graph status via connectSSE (token out of URL) 2026-06-03 11:08:11 +02:00
dextmorgn
d7f21e9384 refactor(app): stream logs via connectSSE (token out of URL) 2026-06-03 10:56:32 +02:00
dextmorgn
0b60c81361 Merge pull request #155 from Ghraven/fix-investigation-aware-timestamp
fix: use aware timestamp for investigation updates
2026-06-03 09:30:35 +02:00
dextmorgn
6e5f26aaf0 feat(app): add connectSSE helper with header auth and backoff 2026-06-03 09:28:49 +02:00
Ghraven
60cfa16b8d fix: use aware timestamp for investigation updates 2026-06-03 08:37:45 +08:00
dextmorgn
078ac77eee build(app): add fetch-event-source + vitest test runner 2026-06-02 22:45:36 +02:00
dextmorgn
ed8bfc5248 Merge pull request #149 from Ghraven/fix/timezone-aware-service-timestamps
Use timezone-aware UTC timestamps in core services
2026-06-02 21:11:45 +02:00
dextmorgn
438bbe18c4 Merge pull request #150 from Ghraven/fix-social-enricher-output-encoding
fix: read social enricher outputs as utf-8
2026-06-02 21:10:12 +02:00
Ghraven
4e14df6c61 fix: read social enricher outputs as utf-8 2026-06-01 00:32:48 +08:00
Ghraven
b707961e68 fix: use timezone-aware service timestamps 2026-05-31 02:35:11 +08:00
45 changed files with 2178 additions and 1657 deletions

View File

@@ -5,7 +5,9 @@ MASTER_VAULT_KEY_V1=base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM=
NEO4J_URI_BOLT=bolt://neo4j:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=password
VITE_API_URL=http://127.0.0.1:5001
# Dev only (vite dev server / docker-compose.yml). Production images use
# same-origin relative URLs proxied by nginx — leave unset for docker-compose.prod.yml.
VITE_API_URL=http://localhost:5001
# Comma-separated CORS allowed origins. Defaults to http://localhost:5173 (Vite dev) when unset.
ALLOWED_ORIGINS=http://localhost:5173
DATABASE_URL=postgresql://flowsint:flowsint@localhost:5433/flowsint

5
.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
# Force LF on files executed or parsed inside Linux containers,
# regardless of the host OS / core.autocrlf setting.
*.sh text eol=lf
.env.example text eol=lf
Makefile text eol=lf

View File

@@ -2,18 +2,17 @@ PROJECT_ROOT := $(shell pwd)
COMPOSE_DEV := docker compose -f docker-compose.dev.yml
COMPOSE_PROD := docker compose -f docker-compose.prod.yml
COMPOSE_DEPLOY := docker compose -f docker-compose.deploy.yml
.PHONY: \
dev prod deploy \
build-dev build-prod \
up-dev up-prod up-deploy down \
dev prod \
build-dev \
up-dev up-prod down \
infra-dev infra-prod infra-stop-dev infra-stop-prod \
migrate-dev migrate-prod \
alembic-upgrade alembic-downgrade alembic-revision \
api frontend celery \
test install clean check-env open-browser-dev open-browser-prod \
logs-dev logs-prod logs-deploy status \
logs-dev logs-prod status \
regenerate-router
ENV_DIRS := . flowsint-api flowsint-core flowsint-app
@@ -63,18 +62,14 @@ open-browser-dev:
echo "Frontend ready at http://localhost:5173"
prod:
@echo "Starting PROD environment..."
@echo "Starting PROD environment (pre-built images)..."
$(MAKE) check-env
$(MAKE) build-prod
$(COMPOSE_PROD) pull
$(MAKE) up-prod
@echo ""
@echo "Production started!"
@echo " Frontend: http://localhost:5173"
@echo " API: http://localhost:5001"
build-prod:
@echo "Building PROD images..."
$(COMPOSE_PROD) build
@echo " API: http://localhost:5173/api (proxied)"
up-prod:
$(COMPOSE_PROD) up -d
@@ -91,27 +86,11 @@ logs-prod:
$(COMPOSE_PROD) logs -f
open-browser-prod:
@echo "Waiting for frontend on port 80 (Traefik)..."
@bash -c 'until curl -s http://localhost > /dev/null 2>&1; do sleep 2; done'
@open http://localhost 2>/dev/null || \
xdg-open http://localhost 2>/dev/null || \
echo "Frontend ready at http://localhost"
deploy:
@echo "Starting DEPLOY environment (GHCR images)..."
$(MAKE) check-env
$(COMPOSE_DEPLOY) pull
$(COMPOSE_DEPLOY) up -d
@echo ""
@echo "Deploy started!"
@echo " Frontend: http://localhost (via Traefik)"
@echo " API: http://localhost/api"
up-deploy:
$(COMPOSE_DEPLOY) up -d
logs-deploy:
$(COMPOSE_DEPLOY) logs -f
@echo "Waiting for frontend on port 5173..."
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 2; done'
@open http://localhost:5173 2>/dev/null || \
xdg-open http://localhost:5173 2>/dev/null || \
echo "Frontend ready at http://localhost:5173"
migrate-dev:
@echo "Running DEV migrations..."
@@ -177,7 +156,6 @@ status:
down:
-$(COMPOSE_DEV) down
-$(COMPOSE_PROD) down
-$(COMPOSE_DEPLOY) down
clean:
@echo "This will remove ALL Docker data. Continue? [y/N]"
@@ -185,7 +163,6 @@ clean:
if [ "$$confirm" != "y" ]; then exit 1; fi
-$(COMPOSE_DEV) down -v --rmi all --remove-orphans
-$(COMPOSE_PROD) down -v --rmi all --remove-orphans
-$(COMPOSE_DEPLOY) down -v --rmi all --remove-orphans
rm -rf flowsint-app/node_modules
rm -rf .venv
@@ -203,17 +180,11 @@ help:
@echo " make logs-dev - Follow DEV logs"
@echo " make infra-dev - Start only infra (postgres/redis/neo4j)"
@echo ""
@echo "Production (local build):"
@echo " make prod - Start PROD environment (local build + Traefik)"
@echo " make build-prod - Build PROD images"
@echo "Production (pre-built GHCR images):"
@echo " make prod - Pull images and start PROD environment"
@echo " make up-prod - Start PROD containers"
@echo " make logs-prod - Follow PROD logs"
@echo ""
@echo "Deploy (GHCR images):"
@echo " make deploy - Start with GHCR images (no build)"
@echo " make up-deploy - Start DEPLOY containers"
@echo " make logs-deploy - Follow DEPLOY logs"
@echo ""
@echo "Local (no Docker):"
@echo " make api - Run API locally"
@echo " make frontend - Run frontend locally"

View File

@@ -31,6 +31,8 @@ Flowsint is still in early development and definetly needs the help of the commu
Don't want to read ? Got it. Here's your install instructions:
### Linux / macOS
#### 1. Install pre-requisites
- Docker
@@ -44,11 +46,76 @@ cd flowsint
make prod
```
### Windows
No Make needed. Works in both **Command Prompt (cmd)** and **PowerShell**.
#### 1. Install pre-requisites
- [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/) (make sure it is **running** before the next step)
- Git
#### 2. Clone and set up environment files
```bat
git clone https://github.com/reconurge/flowsint.git
cd flowsint
copy .env.example .env
copy .env.example flowsint-api\.env
copy .env.example flowsint-core\.env
copy .env.example flowsint-app\.env
```
#### 3. Start
```bat
docker compose -f docker-compose.prod.yml up -d
```
This pulls the pre-built images from GitHub Container Registry — no local build needed.
### First login
Then go to [http://localhost:5173/register](http://localhost:5173/register) and create an account. There are no credentials or account by default.
> ✅ OSINT investigations need a high level of privacy. Everything is stored on your machine.
### Deploy on a network (team / server)
The same setup works out of the box on a server: the frontend serves the UI **and** proxies all API calls internally, so no extra configuration is needed for clients.
```bash
git clone https://github.com/reconurge/flowsint.git
cd flowsint
cp .env.example .env
# Edit .env — see "Before exposing to a network" below
docker compose -f docker-compose.prod.yml up -d
```
Anyone on the network can then access Flowsint at `http://<server-ip>:5173`.
**Before exposing to a network, change the default secrets in `.env`:**
- `AUTH_SECRET` — signs authentication tokens. Generate one: `openssl rand -hex 32`
- `MASTER_VAULT_KEY_V1` — encrypts stored API keys. Generate one: `python3 -c "import os, base64; print('base64:' + base64.b64encode(os.urandom(32)).decode())"`
- `NEO4J_PASSWORD` — Neo4j database password.
Only port `5173` is exposed to the network. PostgreSQL, Redis, Neo4j and the API are bound to `127.0.0.1` on the server and reachable only through the frontend proxy.
To pin a specific version instead of `latest`, set `FLOWSINT_VERSION` in `.env` (e.g. `FLOWSINT_VERSION=1.2.10`).
**HTTPS (recommended beyond a trusted LAN):** put any reverse proxy in front of port 5173. Example with [Caddy](https://caddyserver.com/):
```
flowsint.example.com {
reverse_proxy 127.0.0.1:5173
}
```
When fronting with a reverse proxy, also bind the app port to localhost in `docker-compose.prod.yml` (`"127.0.0.1:5173:8080"`) so clients can only go through HTTPS.
## What is it?
@@ -144,12 +211,19 @@ flowsint-types (types)
### Run
Make sure you have **Make** installed.
**Linux / macOS** (requires **Make**):
```bash
make dev
```
**Windows** (cmd or PowerShell, no Make — create the `.env` files first, see [Get started](#windows)):
```bat
docker compose -f docker-compose.dev.yml up -d --build
docker compose -f docker-compose.dev.yml logs -f
```
### Development
The app is accessible at [http://localhost:5173](http://localhost:5173).

View File

@@ -10,7 +10,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flowsint}
POSTGRES_DB: ${POSTGRES_DB:-flowsint}
ports:
- "5433:5432"
# Bound to localhost: host-only debugging access, never exposed to the network.
- "127.0.0.1:5433:5432"
volumes:
- pg_data_prod:/var/lib/postgresql/data
networks:
@@ -26,7 +27,7 @@ services:
container_name: flowsint-redis-prod
restart: always
ports:
- "6379:6379"
- "127.0.0.1:6379:6379"
networks:
- flowsint_network
healthcheck:
@@ -40,8 +41,8 @@ services:
container_name: flowsint-neo4j-prod
restart: always
ports:
- "7474:7474"
- "7687:7687"
- "127.0.0.1:7474:7474"
- "127.0.0.1:7687:7687"
environment:
- NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
- NEO4J_PLUGINS=["apoc"]
@@ -62,14 +63,13 @@ services:
retries: 10
api:
build:
context: .
dockerfile: flowsint-api/Dockerfile
target: production
image: ghcr.io/reconurge/flowsint-api:${FLOWSINT_VERSION:-latest}
container_name: flowsint-api-prod
restart: always
ports:
- "5001:5001"
# The frontend's nginx proxies /api/ to this service over the Docker
# network — direct access is host-only, for debugging.
- "127.0.0.1:5001:5001"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
@@ -96,10 +96,7 @@ services:
- flowsint_network
celery:
build:
context: .
dockerfile: flowsint-api/Dockerfile
target: production
image: ghcr.io/reconurge/flowsint-api:${FLOWSINT_VERSION:-latest}
container_name: flowsint-celery-prod
restart: always
command:
@@ -144,14 +141,13 @@ services:
- flowsint_network
app:
build:
context: ./flowsint-app
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL}
# Frontend bundle uses same-origin relative URLs; nginx inside the image
# proxies /api/ to the "api" service. No VITE_API_URL needed.
image: ghcr.io/reconurge/flowsint-app:${FLOWSINT_VERSION:-latest}
container_name: flowsint-app-prod
restart: always
ports:
# The only network-facing port. Serves the UI and proxies /api/ to the API.
- "5173:8080"
networks:
- flowsint_network

View File

@@ -1,11 +1,10 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from flowsint_core.core.auth import ALGORITHM, AUTH_SECRET
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Profile
from typing import Optional
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -29,42 +28,3 @@ def get_current_user(
if user is None:
raise credentials_exception
return user
def get_current_user_sse(
request: Request, db: Session = Depends(get_db)
) -> Profile:
"""
Alternative authentication for SSE endpoints that accepts token via query parameter.
EventSource API doesn't support custom headers, so we need to pass the token in the URL.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
# Try to get token from query parameter
token: Optional[str] = request.query_params.get("token")
# Fallback to Authorization header if query param not present
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "")
if not token:
raise credentials_exception
try:
payload = jwt.decode(token, AUTH_SECRET, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(Profile).filter(Profile.email == email).first()
if user is None:
raise credentials_exception
return user

View File

@@ -14,7 +14,7 @@ from flowsint_core.core.services import (
PermissionDeniedError,
DatabaseError,
)
from app.api.deps import get_current_user, get_current_user_sse
from app.api.deps import get_current_user
router = APIRouter()
@@ -42,7 +42,7 @@ async def stream_events(
request: Request,
sketch_id: str,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user_sse),
current_user: Profile = Depends(get_current_user),
):
"""Stream events for a specific sketch in real-time."""
service = create_log_service(db)
@@ -58,7 +58,7 @@ async def stream_events(
channel = sketch_id
await event_emitter.subscribe(channel)
try:
yield 'data: {"event": "connected", "data": "Connected to log stream"}\n\n'
yield json.dumps({"event": "connected", "data": "Connected to log stream"})
while True:
if await request.is_disconnected():
break
@@ -115,7 +115,7 @@ async def stream_sketch_status(
request: Request,
sketch_id: str,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user_sse),
current_user: Profile = Depends(get_current_user),
):
"""Stream COMPLETED events for a specific sketch (for graph refresh)."""
service = create_log_service(db)
@@ -160,49 +160,3 @@ async def stream_sketch_status(
"X-Accel-Buffering": "no",
},
)
@router.get("/status/scan/{scan_id}/stream")
async def stream_status(
request: Request,
scan_id: str,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user_sse),
):
"""Stream status updates for a specific scan in real-time."""
service = create_log_service(db)
try:
service.get_scan_with_permission(scan_id, current_user.id)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
async def status_generator():
print("[EventEmitter] Start status generator")
await event_emitter.subscribe(f"scan_{scan_id}_status")
try:
yield 'data: {"event": "connected", "data": "Connected to status stream"}\n\n'
while True:
data = await event_emitter.get_message(f"scan_{scan_id}_status")
if data is None:
await asyncio.sleep(0.1)
continue
print(f"[EventEmitter] Received status data: {data}")
yield f"data: {data}\n\n"
except asyncio.CancelledError:
print(f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}")
finally:
await event_emitter.unsubscribe(f"scan_{scan_id}_status")
return EventSourceResponse(
status_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -101,7 +101,7 @@ def is_root_domain(domain: str) -> bool:
return False
def is_valid_number(phone: str, region: str = "FR") -> None:
def is_valid_number(phone: str, region: str = "FR") -> bool:
"""
Validates a phone number. Raises InvalidPhoneNumberError if invalid.
- `region` should be ISO 3166-1 alpha-2 country code (e.g., 'FR' for France)
@@ -113,6 +113,8 @@ def is_valid_number(phone: str, region: str = "FR") -> None:
except NumberParseException:
return False
return True
def parse_asn(asn: str) -> int:
if not is_valid_asn(asn):

View File

@@ -9,7 +9,7 @@ dependencies = [
"flowsint-core",
"flowsint-types",
"flowsint-enrichers",
"fastapi[standard]>=0.115.0,<0.116.0",
"fastapi[standard]>=0.115.0,<1.0.0",
"uvicorn>=0.32.0,<0.33.0",
"redis>=5.0,<6.0",
"celery>=5.3,<6.0",
@@ -28,14 +28,14 @@ dependencies = [
"networkx>=2.6.3,<3.0.0",
"email-validator>=2.2.0,<3.0.0",
"mistralai>=1.9.3,<2.0.0",
"python-multipart>=0.0.20,<0.0.21",
"python-multipart>=0.0.27,<0.1.0",
"openpyxl>=3.1.2,<4.0.0",
"jsonschema>=4.25.1,<5.0.0",
]
[dependency-groups]
dev = [
"black>=25.0,<26.0",
"black>=26.3.1,<27.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",

View File

@@ -0,0 +1,38 @@
"""Pytest harness for flowsint-api. Provides a TestClient backed by an
in-memory SQLite database, overriding the real Postgres get_db dependency."""
import os
# Env required at import time by flowsint-core modules. Set before importing app.
os.environ.setdefault("AUTH_SECRET", "test-secret-please-ignore")
os.environ.setdefault(
"MASTER_VAULT_KEY_V1", "base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM="
)
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from flowsint_core.core.models import Base
from flowsint_core.core.postgre_db import get_db
from app.main import app
@pytest.fixture
def db_session():
engine = create_engine(
"sqlite:///:memory:", connect_args={"check_same_thread": False}
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def client(db_session):
app.dependency_overrides[get_db] = lambda: db_session
yield TestClient(app)
app.dependency_overrides.clear()

View File

@@ -0,0 +1,50 @@
"""Auth and shape contract for the SSE event endpoints."""
import importlib
import pytest
from flowsint_core.core.auth import create_access_token
from flowsint_core.core.models import Profile
def test_get_current_user_sse_is_removed():
"""The query-param SSE auth helper must no longer exist."""
deps = importlib.import_module("app.api.deps")
assert not hasattr(deps, "get_current_user_sse")
def test_log_stream_requires_auth_header(client):
"""No Authorization header -> 401 (no token in URL accepted)."""
res = client.get("/api/events/sketch/abc/stream")
assert res.status_code == 401
def test_status_stream_requires_auth_header(client):
res = client.get("/api/events/sketch/abc/status/stream")
assert res.status_code == 401
def test_log_stream_rejects_token_query_param(client):
"""A token in the URL must NOT authenticate the request anymore."""
token = create_access_token({"sub": "user@example.com"})
res = client.get(f"/api/events/sketch/abc/stream?token={token}")
assert res.status_code == 401
def test_dead_scan_stream_endpoint_removed(client):
"""The unused scan status stream endpoint must be gone."""
res = client.get("/api/events/status/scan/abc/stream")
assert res.status_code == 404
def test_get_current_user_accepts_valid_bearer(db_session):
"""The dependency the streams now use authenticates via a valid JWT."""
from app.api.deps import get_current_user
db_session.add(Profile(email="user@example.com", hashed_password="x"))
db_session.commit()
token = create_access_token({"sub": "user@example.com"})
user = get_current_user(token=token, db=db_session)
assert user.email == "user@example.com"

View File

@@ -10,8 +10,9 @@ RUN yarn install --network-timeout 100000
COPY . .
# Build argument for API URL (injected at build time)
ARG VITE_API_URL
# API URL baked at build time. Empty = same-origin relative URLs (/api/...),
# served through the nginx reverse proxy below. Only override for an external API origin.
ARG VITE_API_URL=""
ENV VITE_API_URL=${VITE_API_URL}
RUN yarn build

View File

@@ -57,6 +57,26 @@ http {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API reverse proxy — same-origin, no CORS needed.
# Expects the API service to be reachable as "api:5001" on the Docker network.
location /api/ {
# Resolve at request time via Docker's embedded DNS: survives api
# container restarts and lets nginx boot before api is up.
resolver 127.0.0.11 valid=10s;
set $api_upstream http://api:5001;
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE support (event/chat streams)
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# Health check endpoint
location /health {
access_log off;

View File

@@ -11,6 +11,7 @@
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
@@ -19,6 +20,7 @@
"@ai-sdk/react": "^3.0.79",
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@microsoft/fetch-event-source": "^2.0.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
@@ -139,6 +141,7 @@
"react-dom": "^19.2.0",
"standard-version": "^9.5.0",
"typescript": "^5.5.2",
"vite": "^5.3.1"
"vite": "^5.3.1",
"vitest": "^2.1.9"
}
}

View File

@@ -1,6 +1,6 @@
import { useAuthStore } from '@/stores/auth-store'
const API_URL = import.meta.env.VITE_API_URL
const API_URL = import.meta.env.VITE_API_URL ?? ''
export async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise<any> {
const token = useAuthStore.getState().token

View File

@@ -1,7 +1,7 @@
import { DefaultChatTransport } from 'ai'
import { useAuthStore } from '@/stores/auth-store'
const API_URL = import.meta.env.VITE_API_URL
const API_URL = import.meta.env.VITE_API_URL ?? ''
export function createChatTransport(chatId: string) {
return new DefaultChatTransport({

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Capture the options passed to fetchEventSource so we can drive its callbacks.
const calls: any[] = []
const fetchEventSourceMock = vi.fn((url: string, opts: any) => {
calls.push({ url, opts })
return new Promise(() => {}) // never resolves; we drive callbacks manually
})
vi.mock('@microsoft/fetch-event-source', () => ({
fetchEventSource: (url: string, opts: any) => fetchEventSourceMock(url, opts),
}))
vi.mock('@/stores/auth-store', () => ({
useAuthStore: { getState: () => ({ token: 'tok123' }) },
}))
import { connectSSE } from './sse'
const lastOpts = () => calls[calls.length - 1].opts
const fakeResponse = (status: number, contentType: string) =>
({ ok: status >= 200 && status < 300, status, headers: { get: () => contentType } } as any)
beforeEach(() => {
calls.length = 0
fetchEventSourceMock.mockClear()
})
describe('connectSSE', () => {
it('passes the URL and Authorization header from the store', () => {
connectSSE({ url: 'http://x/stream', onMessage: () => {} })
expect(calls[0].url).toBe('http://x/stream')
expect(lastOpts().headers).toEqual({ Authorization: 'Bearer tok123' })
})
it('forwards parsed envelopes and skips "connected"', () => {
const received: any[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: JSON.stringify({ event: 'connected', data: 'hi' }) })
o.onmessage({ data: JSON.stringify({ event: 'log', data: '{"msg":"a"}' }) })
expect(received).toEqual([{ event: 'log', data: '{"msg":"a"}' }])
})
it('ignores empty and malformed messages without throwing', () => {
const received: any[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: '' })
o.onmessage({ data: 'not-json' })
expect(received).toEqual([])
})
it('aborts the request when the disposer is called', () => {
const dispose = connectSSE({ url: 'http://x', onMessage: () => {} })
expect(lastOpts().signal.aborted).toBe(false)
dispose()
expect(lastOpts().signal.aborted).toBe(true)
})
it('throws (no retry) on a non-event-stream open', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
await expect(lastOpts().onopen(fakeResponse(401, 'application/json'))).rejects.toThrow()
})
it('accepts a valid event-stream open', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
await expect(
lastOpts().onopen(fakeResponse(200, 'text/event-stream'))
).resolves.toBeUndefined()
})
it('returns increasing backoff for transient errors', () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
const o = lastOpts()
const first = o.onerror(new Error('network'))
const second = o.onerror(new Error('network'))
expect(first).toBe(2000)
expect(second).toBe(4000)
})
it('rethrows fatal errors from onerror to stop retrying', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
const o = lastOpts()
let fatal: unknown
try {
await o.onopen(fakeResponse(403, 'application/json'))
} catch (e) {
fatal = e
}
expect(() => o.onerror(fatal)).toThrow()
})
it('ignores valid JSON that is not an envelope', () => {
const received: any[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: '42' })
o.onmessage({ data: 'null' })
o.onmessage({ data: '{"data":"no-event"}' })
expect(received).toEqual([])
})
it('calls onError once when the connection promise rejects (non-fatal)', async () => {
const errors: unknown[] = []
fetchEventSourceMock.mockImplementationOnce((url: string, opts: any) => {
calls.push({ url, opts })
return Promise.reject(new Error('boom'))
})
connectSSE({ url: 'http://x', onMessage: () => {}, onError: (e) => errors.push(e) })
await Promise.resolve()
await Promise.resolve()
expect(errors).toHaveLength(1)
expect((errors[0] as Error).message).toBe('boom')
})
})

View File

@@ -0,0 +1,72 @@
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { useAuthStore } from '@/stores/auth-store'
/**
* Outer SSE envelope from the server. `data` is forwarded exactly as received;
* for the log/status streams it is itself a JSON-encoded string that the
* consumer parses (e.g. `JSON.parse(envelope.data)`).
*/
export type SSEEnvelope = { event: string; data: unknown }
/** Thrown to tell fetch-event-source to stop retrying (e.g. auth failure). */
class FatalSSEError extends Error {}
const MAX_BACKOFF_MS = 30000
/**
* Open an authenticated SSE connection.
* Reads the bearer token once at connect time and sends it via the
* Authorization header. Returns a disposer that aborts the connection.
*/
export function connectSSE(opts: {
url: string
onMessage: (msg: SSEEnvelope) => void
onError?: (err: unknown) => void
}): () => void {
const controller = new AbortController()
let retry = 0
const token = useAuthStore.getState().token
const headers: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {}
fetchEventSource(opts.url, {
signal: controller.signal,
openWhenHidden: true,
headers,
onopen: async (res) => {
const contentType = res.headers.get('content-type') ?? ''
if (!res.ok || !contentType.includes('text/event-stream')) {
throw new FatalSSEError(`SSE open failed: ${res.status}`)
}
retry = 0
},
onmessage: (ev) => {
if (!ev.data) return
let envelope: SSEEnvelope
try {
envelope = JSON.parse(ev.data) as SSEEnvelope
} catch (error) {
console.error('[connectSSE] malformed envelope:', error, ev.data)
return
}
if (typeof envelope?.event !== 'string') return
if (envelope.event === 'connected') return
opts.onMessage(envelope)
},
onerror: (err) => {
if (err instanceof FatalSSEError) {
opts.onError?.(err)
throw err // stop: no retry on auth/protocol failure
}
// transient failure: retry silently with exponential backoff (onError is not called)
retry += 1
return Math.min(1000 * 2 ** retry, MAX_BACKOFF_MS)
},
}).catch((err) => {
if (!(err instanceof FatalSSEError)) opts.onError?.(err)
})
return () => controller.abort()
}

View File

@@ -3,8 +3,9 @@ import { useQuery } from '@tanstack/react-query'
import { logService } from '@/api/log-service'
import { queryKeys } from '@/api/query-keys'
import { useAuthStore } from '@/stores/auth-store'
import { connectSSE } from '@/api/sse'
const API_URL = import.meta.env.VITE_API_URL
const API_URL = import.meta.env.VITE_API_URL ?? ''
export function useEvents(sketch_id: string | undefined) {
@@ -30,45 +31,21 @@ export function useEvents(sketch_id: string | undefined) {
useEffect(() => {
if (!sketch_id || !token) return
const eventSource = new EventSource(
`${API_URL}/api/events/sketch/${sketch_id}/stream?token=${token}`
)
eventSource.onmessage = (e) => {
try {
// Handle malformed SSE data (connection message has extra "data: " prefix)
let dataStr = e.data
if (dataStr.startsWith('data: ')) {
dataStr = dataStr.substring(6) // Remove "data: " prefix
}
const raw = JSON.parse(dataStr) as any
// Ignore connection messages
if (raw.event === 'connected') {
return
}
const dispose = connectSSE({
url: `${API_URL}/api/events/sketch/${sketch_id}/stream`,
onMessage: (raw) => {
// Only process log events
if (raw.event !== 'log') {
return
if (raw.event !== 'log') return
try {
const event = JSON.parse(raw.data as string) as Event
setLiveLogs((prev) => [...prev.slice(-99), event])
} catch (error) {
console.error('[useSketchEvents] Failed to parse log payload:', error, raw.data)
}
},
})
const event = JSON.parse(raw.data) as Event
setLiveLogs((prev) => [...prev.slice(-99), event])
} catch (error) {
console.error('[useSketchEvents] Failed to parse SSE event:', error, e.data)
}
}
eventSource.onerror = (error) => {
console.error('[useSketchEvents] EventSource error:', error)
eventSource.close()
}
return () => {
eventSource.close()
}
return dispose
}, [sketch_id, token])
const logs = useMemo(

View File

@@ -2,8 +2,9 @@ import { useEffect, useRef } from 'react'
import { useGraphControls } from '@/stores/graph-controls-store'
import { useAuthStore } from '@/stores/auth-store'
import { EventLevel } from '@/types'
import { connectSSE } from '@/api/sse'
const API_URL = import.meta.env.VITE_API_URL
const API_URL = import.meta.env.VITE_API_URL ?? ''
export function useGraphRefresh(sketch_id: string | undefined) {
const refetchGraph = useGraphControls((s) => s.refetchGraph)
@@ -25,55 +26,35 @@ export function useGraphRefresh(sketch_id: string | undefined) {
useEffect(() => {
if (!sketch_id || !token) return
const eventSource = new EventSource(
`${API_URL}/api/events/sketch/${sketch_id}/status/stream?token=${token}`
)
eventSource.onmessage = (e) => {
try {
// Handle malformed SSE data (connection message has extra "data: " prefix)
let dataStr = e.data
if (dataStr.startsWith('data: ')) {
dataStr = dataStr.substring(6) // Remove "data: " prefix
}
const raw = JSON.parse(dataStr) as any
// Ignore connection messages
if (raw.event === 'connected') {
return
}
const dispose = connectSSE({
url: `${API_URL}/api/events/sketch/${sketch_id}/status/stream`,
onMessage: (raw) => {
// Only process status events
if (raw.event !== 'status') {
return
}
const event = JSON.parse(raw.data) as any
// Only handle COMPLETED events
if (event.type === EventLevel.COMPLETED) {
const refetch = refetchGraphRef.current
const regenerate = regenerateLayoutRef.current
const layoutType = currentLayoutTypeRef.current
if (raw.event !== 'status') return
try {
const event = JSON.parse(raw.data as string) as any
// Only handle COMPLETED events
if (event.type === EventLevel.COMPLETED) {
const refetch = refetchGraphRef.current
const regenerate = regenerateLayoutRef.current
const layoutType = currentLayoutTypeRef.current
if (typeof refetch !== 'function') {
return
if (typeof refetch !== 'function') return
// Refetch graph data, then regenerate layout if one is active
refetch(() => {
if (layoutType && typeof regenerate === 'function') {
regenerate(layoutType)
}
})
}
// Refetch graph data with callback to regenerate layout after
refetch(() => {
// After refetch completes, regenerate layout if we have one active
if (layoutType && typeof regenerate === 'function') {
regenerate(layoutType)
}
})
} catch (error) {
console.error('[useGraphRefresh] Failed to parse status payload:', error, raw.data)
}
} catch (error) {
console.error('[useGraphRefresh] Failed to parse SSE event:', error, e.data)
}
}
},
})
eventSource.onerror = () => {
eventSource.close()
}
return () => {
eventSource.close()
}
}, [sketch_id, token]) // Only reconnect when sketch_id or token changes
return dispose
}, [sketch_id, token])
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
environment: 'node',
},
})

View File

@@ -23,18 +23,18 @@ dependencies = [
"sse-starlette>=1.8,<2.0",
"alembic==1.13.0",
"phonenumbers>=9.0.8,<10.0.0",
"python-multipart>=0.0.20,<0.0.21",
"python-multipart>=0.0.27,<0.1.0",
"docker>=7.1.0,<8.0.0",
"pytest>=8.4.2,<9.0.0",
"cryptography>=45.0.7,<46.0.0",
"pytest>=9.0.3,<10.0.0",
"cryptography>=46.0.7,<47.0.0",
"openpyxl>=3.1,<4.0",
]
[dependency-groups]
dev = [
"pytest-asyncio>=0.21,<0.22",
"pytest-httpx>=0.35,<0.36",
"black>=25.0,<26.0",
"pytest-asyncio>=0.21,<2.0",
"pytest-httpx>=0.36,<1.0",
"black>=26.3.1,<27.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",

View File

@@ -2,9 +2,9 @@
Analysis service for managing analyses within investigations.
"""
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4
from datetime import datetime
from sqlalchemy.orm import Session
@@ -64,8 +64,8 @@ class AnalysisService(BaseService):
content=content,
owner_id=owner_id,
investigation_id=investigation_id,
created_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
)
self._analysis_repo.add(new_analysis)
self._commit()
@@ -97,7 +97,7 @@ class AnalysisService(BaseService):
self._check_permission(user_id, investigation_id, ["update"])
analysis.investigation_id = investigation_id
analysis.last_updated_at = datetime.utcnow()
analysis.last_updated_at = datetime.now(timezone.utc)
self._commit()
self._refresh(analysis)
return analysis

View File

@@ -4,7 +4,7 @@ Chat service for managing chats and messages with AI integration.
import json
import os
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Dict, List, Optional
from uuid import UUID, uuid4
@@ -76,8 +76,8 @@ class ChatService(BaseService):
description=description,
owner_id=owner_id,
investigation_id=investigation_id,
created_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
)
self._chat_repo.add(new_chat)
self._commit()
@@ -103,7 +103,7 @@ class ChatService(BaseService):
if not chat:
raise NotFoundError("Chat not found")
chat.last_updated_at = datetime.utcnow()
chat.last_updated_at = datetime.now(timezone.utc)
user_message = ChatMessage(
id=uuid4(),
@@ -111,7 +111,7 @@ class ChatService(BaseService):
context=context,
chat_id=chat_id,
is_bot=False,
created_at=datetime.utcnow(),
created_at=datetime.now(timezone.utc),
)
self._chat_repo.add_message(user_message)
self._commit()
@@ -124,7 +124,7 @@ class ChatService(BaseService):
content=content,
chat_id=chat_id,
is_bot=True,
created_at=datetime.utcnow(),
created_at=datetime.now(timezone.utc),
)
self._chat_repo.add_message(chat_message)
self._commit()

View File

@@ -2,7 +2,7 @@
Flow service for managing flows and flow computations.
"""
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4
@@ -82,8 +82,8 @@ class FlowService(BaseService):
description=description,
category=category,
flow_schema=flow_schema,
created_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
)
self._flow_repo.add(new_flow)
self._commit()
@@ -101,7 +101,7 @@ class FlowService(BaseService):
value.append("Username")
setattr(flow, key, value)
flow.last_updated_at = datetime.utcnow()
flow.last_updated_at = datetime.now(timezone.utc)
self._commit()
self._refresh(flow)
return flow

View File

@@ -4,7 +4,7 @@ Investigation service for managing investigations and user roles.
from typing import List, Optional
from uuid import UUID, uuid4
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.orm import Session
@@ -109,7 +109,7 @@ class InvestigationService(BaseService):
investigation.name = name
investigation.description = description
investigation.status = status
investigation.last_updated_at = datetime.utcnow()
investigation.last_updated_at = datetime.now(timezone.utc)
self._commit()
self._refresh(investigation)

View File

@@ -25,7 +25,7 @@ class Vault(VaultProtocol):
if not owner_id:
raise ValueError("owner_id is required to use the vault.")
self.db = db
self.owner_id = str(owner_id)
self.owner_id = owner_id
self.version = "V1"
def _get_master_key(self) -> bytes:
@@ -52,7 +52,7 @@ class Vault(VaultProtocol):
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=self.owner_id.encode("utf-8"),
info=str(self.owner_id).encode("utf-8"),
)
master_key = self._get_master_key()
return hkdf.derive(master_key)
@@ -72,7 +72,7 @@ class Vault(VaultProtocol):
ciphertext = aesgcm.encrypt(
iv,
plaintext.encode("utf-8"),
self.owner_id.encode("utf-8"), # AAD = links secret to user_id
str(self.owner_id).encode("utf-8"), # AAD = links secret to user_id
)
return {
@@ -92,7 +92,7 @@ class Vault(VaultProtocol):
plaintext = aesgcm.decrypt(
row.get("iv"),
row.get("ciphertext"),
self.owner_id.encode("utf-8"),
str(self.owner_id).encode("utf-8"),
)
return plaintext.decode("utf-8")

View File

@@ -23,8 +23,15 @@ def parse_json(
try:
file_bytes = file_bytes.lstrip()
entities: Dict[str, Entity] = {}
my_bytes_value = file_bytes.decode().replace("'", '"')
graph = json.loads(my_bytes_value)
decoded = file_bytes.decode()
# Parse strict JSON first so apostrophes inside string values (e.g.
# "Sarah O'Brien") are preserved. Only fall back to the lenient
# single-quote replacement for Python-dict-style payloads that are not
# valid JSON on their own.
try:
graph = json.loads(decoded)
except json.JSONDecodeError:
graph = json.loads(decoded.replace("'", '"'))
node_key = next((k for k in VALID_NODES_KEYS if k in graph), None)
edge_key = next((k for k in VALID_EDGES_KEYS if k in graph), None)
if node_key is None:

View File

@@ -65,3 +65,40 @@ def test_standard_json_import_without_label():
"""Test basic standard JSON import."""
results = parse_json(standard_json_without_label, max_preview_rows=100)
assert "Username" in results.entities
# Valid JSON whose string values contain apostrophes (very common in OSINT
# data, e.g. surnames like "O'Brien"). This is well-formed JSON and must import.
json_with_apostrophe = b"""{
"nodes": [
{"id": "1", "label": "Sarah O'Brien", "type": "individual"},
{"id": "2", "label": "Bob", "type": "individual"}
],
"edges": [
{"source": "1", "target": "2", "label": "KNOWS"}
]
}"""
def test_json_import_preserves_apostrophes_in_string_values():
"""Valid JSON with an apostrophe in a value must parse and keep the value.
Regression: the parser used to blindly replace every single quote with a
double quote, which corrupted valid JSON (turning "Sarah O'Brien" into
"Sarah O"Brien") and raised "Invalid JSON".
"""
results = parse_json(json_with_apostrophe, max_preview_rows=100)
assert "Individual" in results.entities
labels = {
str(preview.obj.nodeLabel)
for preview in results.entities["Individual"].results
}
assert "Sarah O'Brien" in labels
def test_json_import_still_accepts_single_quoted_payload():
"""Python-dict-style payloads (single-quoted) remain supported as fallback."""
single_quoted = b"{'nodes': [{'id': '1', 'label': 'Alice', 'type': 'individual'}], 'edges': []}"
results = parse_json(single_quoted, max_preview_rows=100)
assert "Individual" in results.entities

View File

@@ -0,0 +1,97 @@
"""Tests for timezone-aware service timestamps."""
from datetime import timezone
from unittest.mock import MagicMock
from uuid import uuid4
from flowsint_core.core.services.analysis_service import AnalysisService
from flowsint_core.core.services.chat_service import ChatService
from flowsint_core.core.services.flow_service import FlowService
from flowsint_core.core.services.investigation_service import InvestigationService
def _assert_utc(value):
assert value.tzinfo is not None
assert value.utcoffset() == timezone.utc.utcoffset(value)
def test_analysis_service_create_uses_timezone_aware_timestamps():
service = AnalysisService(
db=MagicMock(),
analysis_repo=MagicMock(),
investigation_repo=MagicMock(),
)
service._check_permission = MagicMock()
analysis = service.create(
title="Summary",
description=None,
content={},
investigation_id=uuid4(),
owner_id=uuid4(),
)
_assert_utc(analysis.created_at)
_assert_utc(analysis.last_updated_at)
def test_chat_service_create_uses_timezone_aware_timestamps():
service = ChatService(
db=MagicMock(),
chat_repo=MagicMock(),
vault_service=MagicMock(),
)
chat = service.create(
title="Investigation chat",
description=None,
investigation_id=uuid4(),
owner_id=uuid4(),
)
_assert_utc(chat.created_at)
_assert_utc(chat.last_updated_at)
def test_flow_service_create_uses_timezone_aware_timestamps():
service = FlowService(
db=MagicMock(),
flow_repo=MagicMock(),
custom_type_repo=MagicMock(),
sketch_repo=MagicMock(),
investigation_repo=MagicMock(),
)
flow = service.create(
name="Resolve domain",
description=None,
category=["Domain"],
flow_schema={"nodes": [], "edges": []},
)
_assert_utc(flow.created_at)
_assert_utc(flow.last_updated_at)
def test_investigation_service_update_uses_timezone_aware_timestamp():
investigation = MagicMock()
investigation_repo = MagicMock()
investigation_repo.get_by_id.return_value = investigation
service = InvestigationService(
db=MagicMock(),
investigation_repo=investigation_repo,
sketch_repo=MagicMock(),
analysis_repo=MagicMock(),
profile_repo=MagicMock(),
)
service._check_permission = MagicMock()
service.update(
investigation_id=uuid4(),
user_id=uuid4(),
name="Updated investigation",
description="Updated description",
status="active",
)
_assert_utc(investigation.last_updated_at)

View File

@@ -45,7 +45,7 @@ class TestVaultInitialization:
"""Test successful Vault initialization."""
vault = Vault(db=mock_db, owner_id=owner_id)
assert vault.db == mock_db
assert vault.owner_id == str(owner_id)
assert vault.owner_id == owner_id
assert vault.version == "V1"

View File

@@ -28,9 +28,9 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.4.2,<9.0.0",
"pytest-asyncio>=0.21,<0.22",
"black>=25.0,<26.0",
"pytest>=9.0.3,<10.0.0",
"pytest-asyncio>=0.21,<2.0",
"black>=26.3.1,<27.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",

View File

@@ -33,6 +33,7 @@ class DomainToAsnEnricher(Enricher):
vault=vault,
params=params,
)
self.domain_asn_mapping: List[tuple[Domain, ASN]] = []
@classmethod
def required_params(cls) -> bool:
@@ -64,6 +65,7 @@ class DomainToAsnEnricher(Enricher):
async def scan(self, data: List[InputType]) -> List[OutputType]:
results: List[OutputType] = []
self.domain_asn_mapping = []
asnmap = AsnmapTool()
# Retrieve API key from vault or environment
@@ -87,6 +89,7 @@ class DomainToAsnEnricher(Enricher):
description=asn_data.get("as_name", ""),
)
results.append(asn)
self.domain_asn_mapping.append((domain, asn))
Logger.info(
self.sketch_id,
@@ -115,8 +118,8 @@ class DomainToAsnEnricher(Enricher):
self, results: List[OutputType], input_data: List[InputType] = None
) -> List[OutputType]:
# Create Neo4j relationships between domains and their corresponding ASNs
if input_data and self._graph_service:
for domain, asn in zip(input_data, results):
if self._graph_service:
for domain, asn in self.domain_asn_mapping:
# Create domain node
self.create_node(domain)
# Create ASN node

View File

@@ -14,6 +14,10 @@ class ResolveEnricher(Enricher):
InputType = Domain
OutputType = Ip
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.domain_ip_mapping: List[tuple[Domain, Ip]] = []
@classmethod
def name(cls) -> str:
return "domain_to_ip"
@@ -33,10 +37,13 @@ class ResolveEnricher(Enricher):
async def scan(self, data: List[InputType]) -> List[OutputType]:
results: List[OutputType] = []
self.domain_ip_mapping = []
for d in data:
try:
ip = socket.gethostbyname(d.domain)
results.append(Ip(address=ip))
ip_obj = Ip(address=ip)
results.append(ip_obj)
self.domain_ip_mapping.append((d, ip_obj))
except Exception as e:
Logger.info(
self.sketch_id,
@@ -46,7 +53,9 @@ class ResolveEnricher(Enricher):
return results
def postprocess(self, results: List[OutputType], original_input: List[InputType]) -> List[OutputType]:
for domain_obj, ip_obj in zip(original_input, results):
for domain_obj, ip_obj in self.domain_ip_mapping:
if not self._graph_service:
continue
self.create_node(domain_obj)
self.create_node(ip_obj)
self.create_relationship(

View File

@@ -15,6 +15,10 @@ class EmailToGravatarEnricher(Enricher):
InputType = Email
OutputType = Gravatar
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.email_gravatar_mapping: List[tuple[Email, Gravatar]] = []
@classmethod
def name(cls) -> str:
return "email_to_gravatar"
@@ -29,6 +33,7 @@ class EmailToGravatarEnricher(Enricher):
async def scan(self, data: List[InputType]) -> List[OutputType]:
results: List[OutputType] = []
self.email_gravatar_mapping = []
for email in data:
try:
@@ -72,6 +77,7 @@ class EmailToGravatarEnricher(Enricher):
gravatar = Gravatar(**gravatar_data)
results.append(gravatar)
self.email_gravatar_mapping.append((email, gravatar))
except Exception as e:
Logger.error(
@@ -85,13 +91,12 @@ class EmailToGravatarEnricher(Enricher):
return results
def postprocess(self, results: List[OutputType], original_input: List[InputType]) -> List[OutputType]:
for email_obj, gravatar_obj in zip(original_input, results):
for email_obj, gravatar_obj in self.email_gravatar_mapping:
if not self._graph_service:
continue
# Create email node
self.create_node(email_obj)
# Create gravatar node
gravatar_key = f"{email_obj.email}_{self.sketch_id}"
self.create_node(gravatar_obj)
# Create relationship between email and gravatar
self.create_relationship(email_obj, gravatar_obj, "HAS_GRAVATAR")

View File

@@ -32,6 +32,7 @@ class IpToAsnEnricher(Enricher):
vault=vault,
params=params,
)
self.ip_asn_mapping: List[tuple[Ip, ASN]] = []
@classmethod
def required_params(cls) -> bool:
@@ -63,6 +64,7 @@ class IpToAsnEnricher(Enricher):
async def scan(self, data: List[InputType]) -> List[OutputType]:
results: List[OutputType] = []
self.ip_asn_mapping = []
asnmap = AsnmapTool()
# Retrieve API key from vault or environment
@@ -84,6 +86,7 @@ class IpToAsnEnricher(Enricher):
description=asn_data.get("as_name", ""),
)
results.append(asn)
self.ip_asn_mapping.append((ip, asn))
Logger.info(
self.sketch_id,
{
@@ -110,8 +113,8 @@ class IpToAsnEnricher(Enricher):
self, results: List[OutputType], input_data: List[InputType] = None
) -> List[OutputType]:
# Create Neo4j relationships between IPs and their corresponding ASNs
if input_data and self._graph_service:
for ip, asn in zip(input_data, results):
if self._graph_service:
for ip, asn in self.ip_asn_mapping:
# Create IP node
self.create_node(ip)
# Create ASN node

View File

@@ -33,6 +33,7 @@ class IpToFraudScore(Enricher):
vault=vault,
params=params,
)
self.ip_risk_mapping: List[tuple[Ip, RiskProfile]] = []
# @classmethod
# def required_params(cls) -> bool:
@@ -70,6 +71,7 @@ class IpToFraudScore(Enricher):
async def scan(self, data: List[InputType]) -> List[OutputType]:
results: List[OutputType] = []
self.ip_risk_mapping = []
api_username = self.get_secret("SCAMLYTICS_USERNAME", os.getenv("SCAMLYTICS_USERNAME"))
api_key = self.get_secret("SCAMLYTICS_API_KEY", os.getenv("SCAMLYTICS_API_KEY"))
@@ -98,7 +100,7 @@ class IpToFraudScore(Enricher):
Logger.error(
self.sketch_id,
{
"message": f"(IpToFraudScore) Request to Scamlytics failed (status): '{response_json["status"]}'."
"message": f"(IpToFraudScore) Request to Scamlytics failed (status): '{response_json['status']}'."
},
)
continue
@@ -121,11 +123,11 @@ class IpToFraudScore(Enricher):
if proxy_info.get(key):
proxy_flags.append(label)
results.append(
RiskProfile(
entity_id=ip.address, entity_type="IP address", overall_risk_score=fraud_score, risk_level=fraud_risk, last_updated=datetime.datetime.utcnow().isoformat(), risk_factors=proxy_flags, source="Scamalytics"
)
)
risk_profile = RiskProfile(
entity_id=ip.address, entity_type="IP address", overall_risk_score=fraud_score, risk_level=fraud_risk, last_updated=datetime.datetime.utcnow().isoformat(), risk_factors=proxy_flags, source="Scamalytics"
)
results.append(risk_profile)
self.ip_risk_mapping.append((ip, risk_profile))
except Exception as e:
Logger.error(self.sketch_id, {"message": f"(IpToFraudScore) Exception while querying {ip.address}: {e}"})
@@ -138,16 +140,15 @@ class IpToFraudScore(Enricher):
if not self._graph_service:
return results
if input_data and self._graph_service:
for ip, risk_profile in zip(input_data, results):
self.create_node(ip)
self.create_node(risk_profile)
for ip, risk_profile in self.ip_risk_mapping:
self.create_node(ip)
self.create_node(risk_profile)
# Create relationship
self.create_relationship(ip, risk_profile, "HAS_RISK_PROFILE")
self.log_graph_message(
f"(IpToFraudScore) IP {ip.address} has risk level of {risk_profile.risk_level}"
)
# Create relationship
self.create_relationship(ip, risk_profile, "HAS_RISK_PROFILE")
self.log_graph_message(
f"(IpToFraudScore) IP {ip.address} has risk level of {risk_profile.risk_level}"
)
return results

View File

@@ -34,9 +34,8 @@ class IgnorantEnricher(Enricher):
results: List[OutputType] = []
for phone_obj in data:
try:
cleaned_phone = is_valid_number(phone_obj.number)
if cleaned_phone:
result = await self._perform_ignorant_research(cleaned_phone)
if is_valid_number(phone_obj.number):
result = await self._perform_ignorant_research(phone_obj.number)
results.append(result)
else:
results.append({"number": phone_obj.number, "error": "Invalid phone number"})

View File

@@ -53,7 +53,7 @@ class MaigretEnricher(Enricher):
return results
try:
with open(output_file, "r") as f:
with open(output_file, "r", encoding="utf-8") as f:
raw_data = json.load(f)
except Exception as e:
Logger.error(

View File

@@ -63,7 +63,7 @@ class SherlockEnricher(Enricher):
continue
found_accounts = {}
with open(output_file, "r") as f:
with open(output_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and line.startswith("http"):

View File

@@ -155,7 +155,7 @@ def get_root_domain(domain: str) -> str:
return domain
def is_valid_number(phone: str, region: str = "FR") -> None:
def is_valid_number(phone: str, region: str = "FR") -> bool:
"""
Validates a phone number. Raises InvalidPhoneNumberError if invalid.
- `region` should be ISO 3166-1 alpha-2 country code (e.g., 'FR' for France)
@@ -167,6 +167,8 @@ def is_valid_number(phone: str, region: str = "FR") -> None:
except NumberParseException:
return False
return True
def parse_asn(asn: str) -> int:
if not is_valid_asn(asn):

View File

@@ -13,8 +13,8 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.4.2,<9.0.0",
"black>=25.0,<26.0",
"pytest>=9.0.3,<10.0.0",
"black>=26.3.1,<27.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",

View File

@@ -10,7 +10,7 @@ dependencies = [
"flowsint-enrichers",
"flowsint-api",
"pydantic[email]>=2.11.7,<3.0.0",
"python-multipart>=0.0.20,<0.0.21",
"python-multipart>=0.0.27,<0.1.0",
"docker>=7.1.0,<8.0.0",
]

2596
uv.lock generated

File diff suppressed because it is too large Load Diff

218
yarn.lock
View File

@@ -956,6 +956,11 @@
pbf "^4.0.1"
supercluster "^8.0.1"
"@microsoft/fetch-event-source@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d"
integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==
"@monaco-editor/loader@^1.5.0":
version "1.7.0"
resolved "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz"
@@ -2580,6 +2585,65 @@
"@types/babel__core" "^7.20.5"
react-refresh "^0.17.0"
"@vitest/expect@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.9.tgz#b566ea20d58ea6578d8dc37040d6c1a47ebe5ff8"
integrity sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==
dependencies:
"@vitest/spy" "2.1.9"
"@vitest/utils" "2.1.9"
chai "^5.1.2"
tinyrainbow "^1.2.0"
"@vitest/mocker@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.9.tgz#36243b27351ca8f4d0bbc4ef91594ffd2dc25ef5"
integrity sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==
dependencies:
"@vitest/spy" "2.1.9"
estree-walker "^3.0.3"
magic-string "^0.30.12"
"@vitest/pretty-format@2.1.9", "@vitest/pretty-format@^2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz#434ff2f7611689f9ce70cd7d567eceb883653fdf"
integrity sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==
dependencies:
tinyrainbow "^1.2.0"
"@vitest/runner@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.9.tgz#cc18148d2d797fd1fd5908d1f1851d01459be2f6"
integrity sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==
dependencies:
"@vitest/utils" "2.1.9"
pathe "^1.1.2"
"@vitest/snapshot@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.9.tgz#24260b93f798afb102e2dcbd7e61c6dfa118df91"
integrity sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==
dependencies:
"@vitest/pretty-format" "2.1.9"
magic-string "^0.30.12"
pathe "^1.1.2"
"@vitest/spy@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.9.tgz#cb28538c5039d09818b8bfa8edb4043c94727c60"
integrity sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==
dependencies:
tinyspy "^3.0.2"
"@vitest/utils@2.1.9":
version "2.1.9"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.9.tgz#4f2486de8a54acf7ecbf2c5c24ad7994a680a6c1"
integrity sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==
dependencies:
"@vitest/pretty-format" "2.1.9"
loupe "^3.1.2"
tinyrainbow "^1.2.0"
"@webgpu/types@^0.1.69":
version "0.1.69"
resolved "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz"
@@ -2831,6 +2895,11 @@ arrify@^1.0.1:
resolved "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz"
integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==
assertion-error@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7"
integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==
assign-symbols@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz"
@@ -2990,6 +3059,11 @@ bytewise@^1.1.0:
bytewise-core "^1.2.2"
typewise "^1.0.3"
cac@^6.7.14:
version "6.7.14"
resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959"
integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==
cachedir@2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz"
@@ -3057,6 +3131,17 @@ ccount@^2.0.0:
resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
chai@^5.1.2:
version "5.3.3"
resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06"
integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==
dependencies:
assertion-error "^2.0.1"
check-error "^2.1.1"
deep-eql "^5.0.1"
loupe "^3.1.0"
pathval "^2.0.0"
chalk@^2.4.1, chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz"
@@ -3119,6 +3204,11 @@ chardet@^0.7.0:
resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
check-error@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5"
integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==
chokidar@^3.6.0:
version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
@@ -3862,7 +3952,7 @@ dateformat@^3.0.0:
resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz"
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2:
debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.7:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -3904,6 +3994,11 @@ deep-diff@^1.0.2:
resolved "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz"
integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==
deep-eql@^5.0.1:
version "5.0.2"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341"
integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
@@ -4208,6 +4303,11 @@ es-iterator-helpers@^1.2.1:
iterator.prototype "^1.1.4"
safe-array-concat "^1.1.3"
es-module-lexer@^1.5.4:
version "1.7.0"
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a"
integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz"
@@ -4441,6 +4541,13 @@ estree-util-is-identifier-name@^3.0.0:
resolved "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz"
integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==
estree-walker@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==
dependencies:
"@types/estree" "^1.0.0"
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
@@ -4468,6 +4575,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
dependencies:
homedir-polyfill "^1.0.1"
expect-type@^1.1.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68"
integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==
extend-shallow@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz"
@@ -5984,6 +6096,11 @@ loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
loupe@^3.1.0, loupe@^3.1.2:
version "3.2.1"
resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76"
integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==
lowlight@^1.17.0:
version "1.20.0"
resolved "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz"
@@ -6020,7 +6137,7 @@ lucide-react@^0.511.0:
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz"
integrity sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==
magic-string@^0.30.19:
magic-string@^0.30.12, magic-string@^0.30.19:
version "0.30.21"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
@@ -7026,11 +7143,21 @@ path-type@^3.0.0:
dependencies:
pify "^3.0.0"
pathe@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
pathval@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d"
integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==
pbf@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz"
@@ -8041,6 +8168,11 @@ side-channel@^1.1.0:
side-channel-map "^1.0.1"
side-channel-weakmap "^1.0.2"
siginfo@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
signal-exit@^3.0.2:
version "3.0.7"
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
@@ -8150,6 +8282,11 @@ split@^1.0.0:
dependencies:
through "2"
stackback@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
standard-version@^9.5.0:
version "9.5.0"
resolved "https://registry.npmjs.org/standard-version/-/standard-version-9.5.0.tgz"
@@ -8175,6 +8312,11 @@ state-local@^1.0.6:
resolved "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz"
integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
std-env@^3.8.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b"
integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==
stop-iteration-iterator@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz"
@@ -8430,11 +8572,21 @@ tiny-warning@^1.0.3:
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinybench@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
tinycolor2@^1.6.0:
version "1.6.0"
resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
tinyexec@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
tinyexec@^1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz"
@@ -8448,11 +8600,26 @@ tinyglobby@^0.2.15:
fdir "^6.5.0"
picomatch "^4.0.3"
tinypool@^1.0.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591"
integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==
tinyqueue@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz"
integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==
tinyrainbow@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5"
integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==
tinyspy@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a"
integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==
tippy.js@^6.3.7:
version "6.3.7"
resolved "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz"
@@ -8838,7 +9005,18 @@ victory-vendor@^36.6.8:
d3-time "^3.0.0"
d3-timer "^3.0.1"
vite@^5.3.1:
vite-node@2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.9.tgz#549710f76a643f1c39ef34bdb5493a944e4f895f"
integrity sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==
dependencies:
cac "^6.7.14"
debug "^4.3.7"
es-module-lexer "^1.5.4"
pathe "^1.1.2"
vite "^5.0.0"
vite@^5.0.0, vite@^5.3.1:
version "5.4.21"
resolved "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz"
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
@@ -8863,6 +9041,32 @@ vite@^7.1.7:
optionalDependencies:
fsevents "~2.3.3"
vitest@^2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.9.tgz#7d01ffd07a553a51c87170b5e80fea3da7fb41e7"
integrity sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==
dependencies:
"@vitest/expect" "2.1.9"
"@vitest/mocker" "2.1.9"
"@vitest/pretty-format" "^2.1.9"
"@vitest/runner" "2.1.9"
"@vitest/snapshot" "2.1.9"
"@vitest/spy" "2.1.9"
"@vitest/utils" "2.1.9"
chai "^5.1.2"
debug "^4.3.7"
expect-type "^1.1.0"
magic-string "^0.30.12"
pathe "^1.1.2"
std-env "^3.8.0"
tinybench "^2.9.0"
tinyexec "^0.3.1"
tinypool "^1.0.1"
tinyrainbow "^1.2.0"
vite "^5.0.0"
vite-node "2.1.9"
why-is-node-running "^2.3.0"
w3c-keyname@^2.2.0:
version "2.2.8"
resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz"
@@ -8947,6 +9151,14 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
why-is-node-running@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04"
integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==
dependencies:
siginfo "^2.0.0"
stackback "0.0.2"
word-wrap@^1.0.3, word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"