deep research addressement
This commit is contained in:
13
.env.example
13
.env.example
@@ -19,6 +19,11 @@ VITE_CLERK_PUBLISHABLE_KEY=""
|
|||||||
# Payments (Stripe)
|
# Payments (Stripe)
|
||||||
STRIPE_SECRET_KEY=""
|
STRIPE_SECRET_KEY=""
|
||||||
STRIPE_WEBHOOK_SECRET=""
|
STRIPE_WEBHOOK_SECRET=""
|
||||||
|
STRIPE_PRICE_BASIC=""
|
||||||
|
STRIPE_PRICE_PLUS=""
|
||||||
|
STRIPE_PRICE_PREMIUM=""
|
||||||
|
STRIPE_PRICE_FAMILY_GUARD=""
|
||||||
|
STRIPE_PRICE_FAMILY_FORTRESS=""
|
||||||
STRIPE_PRICE_PLUS_MONTHLY=""
|
STRIPE_PRICE_PLUS_MONTHLY=""
|
||||||
STRIPE_PRICE_PREMIUM_MONTHLY=""
|
STRIPE_PRICE_PREMIUM_MONTHLY=""
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=""
|
VITE_STRIPE_PUBLISHABLE_KEY=""
|
||||||
@@ -41,12 +46,20 @@ TWILIO_AUTH_TOKEN=""
|
|||||||
TWILIO_MESSAGING_SERVICE_SID=""
|
TWILIO_MESSAGING_SERVICE_SID=""
|
||||||
|
|
||||||
# External APIs
|
# External APIs
|
||||||
|
ATTOM_API_KEY=""
|
||||||
HIBP_API_KEY=""
|
HIBP_API_KEY=""
|
||||||
|
# HIBP rate limit: 1 (free tier, default) or 10 (paid tier)
|
||||||
|
HIBP_RATE_PER_SECOND=1
|
||||||
SECURITYTRAILS_API_KEY=""
|
SECURITYTRAILS_API_KEY=""
|
||||||
CENSYS_API_ID=""
|
CENSYS_API_ID=""
|
||||||
CENSYS_API_SECRET=""
|
CENSYS_API_SECRET=""
|
||||||
SHODAN_API_KEY=""
|
SHODAN_API_KEY=""
|
||||||
|
|
||||||
|
# Azure Speech Services (VoicePrint / Voice Clone Detection)
|
||||||
|
# Sign up: https://azure.microsoft.com/services/cognitive-services/speech-services/
|
||||||
|
AZURE_SPEECH_KEY=""
|
||||||
|
AZURE_SPEECH_REGION="eastus"
|
||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
VITE_SENTRY_DSN=""
|
VITE_SENTRY_DSN=""
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,9 @@ RESEND_API_KEY=""
|
|||||||
DOCKER_TAG=latest
|
DOCKER_TAG=latest
|
||||||
GITHUB_REPOSITORY_OWNER=kordant
|
GITHUB_REPOSITORY_OWNER=kordant
|
||||||
|
|
||||||
|
# Azure Speech Services (VoicePrint / Voice Clone Detection)
|
||||||
|
AZURE_SPEECH_KEY=""
|
||||||
|
AZURE_SPEECH_REGION="eastus"
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -26,3 +26,10 @@ android/app/build
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
honker
|
honker
|
||||||
|
.ralpi
|
||||||
|
# ML training environment
|
||||||
|
.venv-ml
|
||||||
|
ml/spam-classifier/output/data
|
||||||
|
ml/spam-classifier/output/final_model
|
||||||
|
ml/spam-classifier/output/best_model
|
||||||
|
ml/spam-classifier/output/tmp_for_export
|
||||||
|
|||||||
166
ml/spam-classifier/README.md
Normal file
166
ml/spam-classifier/README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# SMS Spam Classifier - DistilBERT ONNX Pipeline
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This directory contains the training pipeline for a fine-tuned DistilBERT SMS spam classifier, exported to ONNX format for fast CPU inference in the Kordant spamshield service.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Training (Python) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
||||||
|
│ │ SMS Spam │ │ DistilBERT │ │ ONNX Export │ │
|
||||||
|
│ │ Collection │→ │ Fine-tuning │→ │ + INT8 Quantize │ │
|
||||||
|
│ │ Dataset │ │ (3 epochs) │ │ │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └───────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Inference (Node.js) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
||||||
|
│ │ Text Input │ │ BertTokenizer│ │ ONNX Runtime │ │
|
||||||
|
│ │ │→ │ (JS impl) │→ │ (onnxruntime-node)│ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └───────────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Result: { isSpam, confidence, score, modelVersion } │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Training
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+ (tested with 3.14)
|
||||||
|
- Virtual environment: `python3 -m venv .venv-ml && source .venv-ml/bin/activate`
|
||||||
|
- Dependencies: `pip install transformers datasets torch optimum[onnxruntime] onnxruntime`
|
||||||
|
|
||||||
|
### Run Training
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full pipeline with data augmentation
|
||||||
|
python ml/spam-classifier/train.py \
|
||||||
|
--output-dir ml/spam-classifier/output \
|
||||||
|
--augment \
|
||||||
|
--epochs 3 \
|
||||||
|
--batch-size 32
|
||||||
|
|
||||||
|
# Skip training (use existing model for export only)
|
||||||
|
python ml/spam-classifier/train.py \
|
||||||
|
--output-dir ml/spam-classifier/output \
|
||||||
|
--skip-training
|
||||||
|
|
||||||
|
# With INT8 quantization
|
||||||
|
python ml/spam-classifier/train.py \
|
||||||
|
--output-dir ml/spam-classifier/output \
|
||||||
|
--augment \
|
||||||
|
--quantize
|
||||||
|
```
|
||||||
|
|
||||||
|
### Training Parameters
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `--epochs` | 3 | Number of training epochs |
|
||||||
|
| `--batch-size` | 32 | Training batch size |
|
||||||
|
| `--lr` | 2e-5 | Learning rate |
|
||||||
|
| `--augment` | false | Include synthetic data augmentation |
|
||||||
|
| `--quantize` | false | Quantize to INT8 for smaller model |
|
||||||
|
| `--skip-training` | false | Skip training, only export |
|
||||||
|
|
||||||
|
### Expected Results
|
||||||
|
|
||||||
|
- **Validation accuracy**: 97-99% on SMS Spam Collection
|
||||||
|
- **Training time**: ~5-10 minutes on M-series Mac, ~2-3 minutes on GPU
|
||||||
|
- **Model size**: ~257MB (FP32), ~128MB (INT8 quantized)
|
||||||
|
|
||||||
|
## Inference
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
The trained ONNX model is deployed to `web/src/server/models/spam-classifier/`.
|
||||||
|
|
||||||
|
The Node.js inference wrapper is in `web/src/server/services/spamshield/onnx.inference.ts`.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { classifyTextBERT, initSpamModel } from "~/server/services/spamshield/onnx.inference";
|
||||||
|
|
||||||
|
// Initialize at startup (loads model once)
|
||||||
|
await initSpamModel();
|
||||||
|
|
||||||
|
// Classify text
|
||||||
|
const result = await classifyTextBERT("Hello world");
|
||||||
|
// { isSpam: false, confidence: 0.97, score: 0.03, modelVersion: "1.0.0" }
|
||||||
|
|
||||||
|
// With threshold mode
|
||||||
|
const strict = await classifyTextBERT("Get free money!", "strict");
|
||||||
|
const lenient = await classifyTextBERT("Get free money!", "lenient");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Threshold Modes
|
||||||
|
|
||||||
|
| Mode | Threshold | Description |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| `strict` | 0.3 | Aggressive spam detection, more false positives |
|
||||||
|
| `moderate` | 0.5 | Balanced (default) |
|
||||||
|
| `lenient` | 0.7 | Conservative, fewer false positives |
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| p50 latency | <5ms |
|
||||||
|
| p95 latency | <30ms |
|
||||||
|
| p99 latency | <50ms |
|
||||||
|
| Throughput | ~240 inferences/sec |
|
||||||
|
| Model load time | ~2s (cold start) |
|
||||||
|
|
||||||
|
### Benchmark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm benchmark:spamshield --iterations 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Versioning
|
||||||
|
|
||||||
|
The model version is tracked in `model_metadata.json` alongside the ONNX files.
|
||||||
|
|
||||||
|
To deploy a new model:
|
||||||
|
1. Run training with new data/hyperparameters
|
||||||
|
2. Update version in `model_metadata.json`
|
||||||
|
3. Copy ONNX files to `web/src/server/models/spam-classifier/`
|
||||||
|
4. Deploy
|
||||||
|
|
||||||
|
## Feedback Loop
|
||||||
|
|
||||||
|
User feedback is stored in the `spam_feedback` database table and can be used for periodic retraining:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT text, is_spam, feedback_type FROM spam_feedback
|
||||||
|
WHERE feedback_type = 'user_rejection'
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
ml/spam-classifier/
|
||||||
|
├── train.py # Training pipeline
|
||||||
|
├── README.md # This file
|
||||||
|
└── output/ # Training output (gitignored except onnx_model)
|
||||||
|
├── data/ # Raw dataset (gitignored)
|
||||||
|
├── final_model/ # PyTorch model (gitignored)
|
||||||
|
├── best_model/ # Best checkpoint (gitignored)
|
||||||
|
└── onnx_model/ # ONNX export (copied to web/)
|
||||||
|
├── model.onnx
|
||||||
|
├── model.onnx.data
|
||||||
|
├── vocab.txt
|
||||||
|
├── tokenizer.json
|
||||||
|
├── tokenizer_config.json
|
||||||
|
└── model_metadata.json
|
||||||
|
```
|
||||||
BIN
ml/spam-classifier/output/onnx_model/model.onnx
Normal file
BIN
ml/spam-classifier/output/onnx_model/model.onnx
Normal file
Binary file not shown.
BIN
ml/spam-classifier/output/onnx_model/model.onnx.data
Normal file
BIN
ml/spam-classifier/output/onnx_model/model.onnx.data
Normal file
Binary file not shown.
17
ml/spam-classifier/output/onnx_model/model_metadata.json
Normal file
17
ml/spam-classifier/output/onnx_model/model_metadata.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"model_name": "distilbert-base-uncased",
|
||||||
|
"task": "sms-spam-classification",
|
||||||
|
"max_length": 128,
|
||||||
|
"num_labels": 2,
|
||||||
|
"label2id": {
|
||||||
|
"ham": 0,
|
||||||
|
"spam": 1
|
||||||
|
},
|
||||||
|
"id2label": {
|
||||||
|
"0": "ham",
|
||||||
|
"1": "spam"
|
||||||
|
},
|
||||||
|
"framework": "pytorch",
|
||||||
|
"export_format": "onnx"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
30686
ml/spam-classifier/output/onnx_model/tokenizer.json
Normal file
30686
ml/spam-classifier/output/onnx_model/tokenizer.json
Normal file
File diff suppressed because it is too large
Load Diff
56
ml/spam-classifier/output/onnx_model/tokenizer_config.json
Normal file
56
ml/spam-classifier/output/onnx_model/tokenizer_config.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"added_tokens_decoder": {
|
||||||
|
"0": {
|
||||||
|
"content": "[PAD]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"100": {
|
||||||
|
"content": "[UNK]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"101": {
|
||||||
|
"content": "[CLS]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"102": {
|
||||||
|
"content": "[SEP]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"103": {
|
||||||
|
"content": "[MASK]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clean_up_tokenization_spaces": false,
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"do_lower_case": true,
|
||||||
|
"extra_special_tokens": {},
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"model_max_length": 512,
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"strip_accents": null,
|
||||||
|
"tokenize_chinese_chars": true,
|
||||||
|
"tokenizer_class": "DistilBertTokenizer",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
30522
ml/spam-classifier/output/onnx_model/vocab.txt
Normal file
30522
ml/spam-classifier/output/onnx_model/vocab.txt
Normal file
File diff suppressed because it is too large
Load Diff
444
ml/spam-classifier/train.py
Normal file
444
ml/spam-classifier/train.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fine-tune DistilBERT on SMS Spam Collection Dataset and export to ONNX.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python train.py [--output-dir ./output] [--epochs 3] [--batch-size 32] [--quantize]
|
||||||
|
|
||||||
|
The SMS Spam Collection dataset is downloaded from UCI ML Repository.
|
||||||
|
The fine-tuned model is exported to ONNX format for fast CPU inference.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from torch.optim.adamw import AdamW
|
||||||
|
from datasets import Dataset, DatasetDict, concatenate_datasets
|
||||||
|
from torch.utils.data import DataLoader
|
||||||
|
from transformers import (
|
||||||
|
AutoTokenizer,
|
||||||
|
DistilBertForSequenceClassification,
|
||||||
|
get_linear_schedule_with_warmup,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MODEL_NAME = "distilbert-base-uncased"
|
||||||
|
MAX_LENGTH = 128
|
||||||
|
LABEL2ID = {"ham": 0, "spam": 1}
|
||||||
|
ID2LABEL = {0: "ham", 1: "spam"}
|
||||||
|
BATCH_SIZE = 32
|
||||||
|
NUM_EPOCHS = 3
|
||||||
|
LEARNING_RATE = 2e-5
|
||||||
|
WARMUP_RATIO = 0.1
|
||||||
|
EVAL_SPLIT = 0.15 # 15% for validation
|
||||||
|
|
||||||
|
# ── Data Loading ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HF_DATASET_NAME = "sms_spam"
|
||||||
|
|
||||||
|
|
||||||
|
def load_dataset(data_dir: Optional[Path] = None) -> DatasetDict:
|
||||||
|
"""Load the SMS Spam Collection dataset from HuggingFace Hub."""
|
||||||
|
print(f" Loading {HF_DATASET_NAME} from HuggingFace Hub...")
|
||||||
|
from datasets import load_dataset as hf_load
|
||||||
|
|
||||||
|
hf_dataset = hf_load(HF_DATASET_NAME, split="train")
|
||||||
|
# Rename columns to match our schema
|
||||||
|
hf_dataset = hf_dataset.rename_column("sms", "text")
|
||||||
|
# HF dataset uses 0=ham, 1=spam already
|
||||||
|
print(f" Total messages: {len(hf_dataset)}")
|
||||||
|
print(f" Spam: {sum(hf_dataset['label'])}, Ham: {len(hf_dataset) - sum(hf_dataset['label'])}")
|
||||||
|
|
||||||
|
# Split into train/val
|
||||||
|
split = hf_dataset.train_test_split(test_size=EVAL_SPLIT, seed=42)
|
||||||
|
return DatasetDict({"train": split["train"], "val": split["test"]})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Synthetic Data Augmentation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
SPAM_TEMPLATES = [
|
||||||
|
"Congratulations! You've won ${amount}! Call {phone} to claim.",
|
||||||
|
"FREE {product}! Text YES to {number} now!",
|
||||||
|
"You have been selected for a ${amount} loan. Reply NOW.",
|
||||||
|
"URGENT: Your account will be closed. Call {phone} immediately.",
|
||||||
|
"Win a FREE iPhone! Visit {url} to claim your prize.",
|
||||||
|
"Dear customer, your ${amount} refund is ready. Click {url}.",
|
||||||
|
"Hot singles in your area! Text {number} to meet them.",
|
||||||
|
"You've been pre-approved for a credit line of ${amount}.",
|
||||||
|
"ACT NOW! Limited time offer: {product} for just ${amount}.",
|
||||||
|
"Your package delivery failed. Call {phone} to reschedule.",
|
||||||
|
"BREAKING: You won the lottery! ${amount} prize. Call {phone}.",
|
||||||
|
"FREE entry to win ${amount} cash prize! Text WIN to {number}.",
|
||||||
|
"Your bank account has been compromised. Call {phone} now.",
|
||||||
|
"Make ${amount} per week working from home. Visit {url}.",
|
||||||
|
"You have a new message from {name}. Reply to view.",
|
||||||
|
]
|
||||||
|
|
||||||
|
HAM_TEMPLATES = [
|
||||||
|
"Hey, are we still on for dinner tonight?",
|
||||||
|
"Can you pick up milk on your way home?",
|
||||||
|
"Meeting at 3pm in conference room B.",
|
||||||
|
"Thanks for the help yesterday!",
|
||||||
|
"I'll be 10 minutes late to the meeting.",
|
||||||
|
"Let me know when you get home.",
|
||||||
|
"Happy birthday! Hope you have a great day.",
|
||||||
|
"The weather is nice today, want to go for a walk?",
|
||||||
|
"I sent you the files, let me know if you got them.",
|
||||||
|
"See you at the game tomorrow!",
|
||||||
|
"Can we reschedule our appointment to next week?",
|
||||||
|
"I forgot my keys, can you let me in?",
|
||||||
|
"The presentation went well, thanks for the feedback.",
|
||||||
|
"Do you have the recipe for that cake?",
|
||||||
|
"Running late, be there in 5 minutes.",
|
||||||
|
]
|
||||||
|
|
||||||
|
PHONE_PLACEHOLDERS = ["555-0123", "800-555-1234", "555-9876"]
|
||||||
|
URL_PLACEHOLDERS = ["http://bit.ly/abc123", "http://short.url/xyz", "http://link.co/prize"]
|
||||||
|
AMOUNT_PLACEHOLDERS = ["1000", "5000", "10000", "500", "25000"]
|
||||||
|
PRODUCT_PLACEHOLDERS = ["iPad", "TV", "Gift Card", "Headphones", "Watch"]
|
||||||
|
NAME_PLACEHOLDERS = ["John", "Sarah", "Mike", "Jessica", "David"]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_synthetic_data(num_samples: int = 1000) -> dict:
|
||||||
|
"""Generate synthetic spam and ham messages for data augmentation."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
random.seed(42)
|
||||||
|
texts = []
|
||||||
|
labels = []
|
||||||
|
|
||||||
|
for _ in range(num_samples):
|
||||||
|
if random.random() < 0.5:
|
||||||
|
# Generate spam
|
||||||
|
template = random.choice(SPAM_TEMPLATES)
|
||||||
|
msg = template.replace("${amount}", random.choice(AMOUNT_PLACEHOLDERS))
|
||||||
|
msg = msg.replace("{phone}", random.choice(PHONE_PLACEHOLDERS))
|
||||||
|
msg = msg.replace("{url}", random.choice(URL_PLACEHOLDERS))
|
||||||
|
msg = msg.replace("{product}", random.choice(PRODUCT_PLACEHOLDERS))
|
||||||
|
msg = msg.replace("{name}", random.choice(NAME_PLACEHOLDERS))
|
||||||
|
msg = msg.replace("{number}", random.choice(PHONE_PLACEHOLDERS))
|
||||||
|
texts.append(msg)
|
||||||
|
labels.append(1)
|
||||||
|
else:
|
||||||
|
# Generate ham
|
||||||
|
texts.append(random.choice(HAM_TEMPLATES))
|
||||||
|
labels.append(0)
|
||||||
|
|
||||||
|
return {"text": texts, "label": labels}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Training ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def train_model(
|
||||||
|
train_dataset: Dataset,
|
||||||
|
val_dataset: Dataset,
|
||||||
|
output_dir: Path,
|
||||||
|
epochs: int = NUM_EPOCHS,
|
||||||
|
batch_size: int = BATCH_SIZE,
|
||||||
|
learning_rate: float = LEARNING_RATE,
|
||||||
|
):
|
||||||
|
"""Fine-tune DistilBERT for SMS spam classification."""
|
||||||
|
print("\n--- Training DistilBERT ---")
|
||||||
|
print(f" Model: {MODEL_NAME}")
|
||||||
|
print(f" Epochs: {epochs}, Batch size: {batch_size}, LR: {learning_rate}")
|
||||||
|
|
||||||
|
device = torch.device("mps" if torch.backends.mps.is_available() else "cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
print(f" Device: {device}")
|
||||||
|
|
||||||
|
# Load tokenizer and model
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
||||||
|
model = DistilBertForSequenceClassification.from_pretrained(
|
||||||
|
MODEL_NAME, num_labels=2
|
||||||
|
)
|
||||||
|
model.to(device)
|
||||||
|
|
||||||
|
# Tokenize datasets
|
||||||
|
def tokenize_fn(examples):
|
||||||
|
return tokenizer(
|
||||||
|
examples["text"],
|
||||||
|
padding="max_length",
|
||||||
|
truncation=True,
|
||||||
|
max_length=MAX_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
train_dataset = train_dataset.map(tokenize_fn, batched=True)
|
||||||
|
val_dataset = val_dataset.map(tokenize_fn, batched=True)
|
||||||
|
|
||||||
|
train_dataset.set_format("torch", columns=["input_ids", "attention_mask", "label"])
|
||||||
|
val_dataset.set_format("torch", columns=["input_ids", "attention_mask", "label"])
|
||||||
|
|
||||||
|
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
|
||||||
|
val_loader = DataLoader(val_dataset, batch_size=batch_size)
|
||||||
|
|
||||||
|
# Optimizer and scheduler
|
||||||
|
optimizer = AdamW(model.parameters(), lr=learning_rate)
|
||||||
|
total_steps = len(train_loader) * epochs
|
||||||
|
warmup_steps = int(total_steps * WARMUP_RATIO)
|
||||||
|
scheduler = get_linear_schedule_with_warmup(
|
||||||
|
optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps
|
||||||
|
)
|
||||||
|
|
||||||
|
# Training loop
|
||||||
|
model.train()
|
||||||
|
best_val_loss = float("inf")
|
||||||
|
best_model_path = output_dir / "best_model"
|
||||||
|
|
||||||
|
for epoch in range(epochs):
|
||||||
|
total_loss = 0
|
||||||
|
for batch in train_loader:
|
||||||
|
optimizer.zero_grad()
|
||||||
|
input_ids = batch["input_ids"].to(device)
|
||||||
|
attention_mask = batch["attention_mask"].to(device)
|
||||||
|
labels = batch["label"].to(device)
|
||||||
|
|
||||||
|
outputs = model(
|
||||||
|
input_ids=input_ids,
|
||||||
|
attention_mask=attention_mask,
|
||||||
|
labels=labels,
|
||||||
|
)
|
||||||
|
loss = outputs.loss
|
||||||
|
loss.backward()
|
||||||
|
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
||||||
|
optimizer.step()
|
||||||
|
scheduler.step()
|
||||||
|
total_loss += loss.item()
|
||||||
|
|
||||||
|
avg_loss = total_loss / len(train_loader)
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
model.eval()
|
||||||
|
val_loss = 0
|
||||||
|
correct = 0
|
||||||
|
total = 0
|
||||||
|
with torch.no_grad():
|
||||||
|
for batch in val_loader:
|
||||||
|
input_ids = batch["input_ids"].to(device)
|
||||||
|
attention_mask = batch["attention_mask"].to(device)
|
||||||
|
labels = batch["label"].to(device)
|
||||||
|
|
||||||
|
outputs = model(
|
||||||
|
input_ids=input_ids,
|
||||||
|
attention_mask=attention_mask,
|
||||||
|
labels=labels,
|
||||||
|
)
|
||||||
|
val_loss += outputs.loss.item()
|
||||||
|
|
||||||
|
predictions = torch.argmax(outputs.logits, dim=-1)
|
||||||
|
correct += (predictions == labels).sum().item()
|
||||||
|
total += labels.size(0)
|
||||||
|
|
||||||
|
model.train()
|
||||||
|
val_acc = correct / total
|
||||||
|
avg_val_loss = val_loss / len(val_loader)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" Epoch {epoch + 1}/{epochs}: "
|
||||||
|
f"Train Loss={avg_loss:.4f}, Val Loss={avg_val_loss:.4f}, "
|
||||||
|
f"Val Acc={val_acc:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save best model
|
||||||
|
if avg_val_loss < best_val_loss:
|
||||||
|
best_val_loss = avg_val_loss
|
||||||
|
model.save_pretrained(best_model_path)
|
||||||
|
print(f" -> Saved best model (val_loss={avg_val_loss:.4f})")
|
||||||
|
|
||||||
|
# Save final model too
|
||||||
|
final_model_path = output_dir / "final_model"
|
||||||
|
model.save_pretrained(final_model_path)
|
||||||
|
tokenizer.save_pretrained(final_model_path)
|
||||||
|
|
||||||
|
print(f"\n--- Training complete ---")
|
||||||
|
print(f" Best val loss: {best_val_loss:.4f}")
|
||||||
|
print(f" Final val accuracy: {val_acc:.4f}")
|
||||||
|
print(f" Model saved to: {final_model_path}")
|
||||||
|
|
||||||
|
return model, tokenizer, val_acc
|
||||||
|
|
||||||
|
|
||||||
|
# ── ONNX Export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def export_onnx(
|
||||||
|
model,
|
||||||
|
tokenizer,
|
||||||
|
output_dir: Path,
|
||||||
|
quantize: bool = False,
|
||||||
|
):
|
||||||
|
"""Export the model to ONNX format."""
|
||||||
|
print("\n--- Exporting to ONNX ---")
|
||||||
|
|
||||||
|
onnx_dir = output_dir / "onnx_model"
|
||||||
|
onnx_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
model.eval()
|
||||||
|
device = next(model.parameters()).device
|
||||||
|
model.to("cpu") # ONNX export needs CPU
|
||||||
|
|
||||||
|
# Simple approach: use torch.onnx.export with dynamic axes
|
||||||
|
print(" Using torch.onnx.export for ONNX conversion...")
|
||||||
|
onnx_path = onnx_dir / "model.onnx"
|
||||||
|
|
||||||
|
# Create dummy inputs with batch size 1
|
||||||
|
dummy_input = tokenizer(
|
||||||
|
"This is a test message for spam detection.",
|
||||||
|
return_tensors="pt",
|
||||||
|
padding="max_length",
|
||||||
|
truncation=True,
|
||||||
|
max_length=MAX_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
torch.onnx.export(
|
||||||
|
model,
|
||||||
|
(dummy_input["input_ids"], dummy_input["attention_mask"]),
|
||||||
|
onnx_path,
|
||||||
|
input_names=["input_ids", "attention_mask"],
|
||||||
|
output_names=["logits"],
|
||||||
|
dynamic_axes={
|
||||||
|
"input_ids": {0: "batch_size"},
|
||||||
|
"attention_mask": {0: "batch_size"},
|
||||||
|
"logits": {0: "batch_size"},
|
||||||
|
},
|
||||||
|
opset_version=18,
|
||||||
|
do_constant_folding=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ONNX model exported to: {onnx_path}")
|
||||||
|
print(f" Model size: {onnx_path.stat().st_size / 1024 / 1024:.1f} MB")
|
||||||
|
|
||||||
|
# Optional: INT8 quantization
|
||||||
|
if quantize:
|
||||||
|
try:
|
||||||
|
from onnxruntime.quantization import quantize_dynamic
|
||||||
|
from onnxruntime.quantization.quantize import QuantType
|
||||||
|
|
||||||
|
print("\n --- Quantizing to INT8 ---")
|
||||||
|
quantized_path = onnx_dir / "model_quantized.onnx"
|
||||||
|
|
||||||
|
quantize_dynamic(
|
||||||
|
model_input=onnx_path,
|
||||||
|
model_output=quantized_path,
|
||||||
|
weight_type=QuantType.QUINT8,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Quantized model: {quantized_path}")
|
||||||
|
print(f" Quantized size: {quantized_path.stat().st_size / 1024 / 1024:.1f} MB")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print(" Skipping quantization (onnxruntime quantization not available)")
|
||||||
|
|
||||||
|
# Save tokenizer files needed by Node.js tokenizer
|
||||||
|
tokenizer.save_pretrained(str(onnx_dir))
|
||||||
|
|
||||||
|
# Save model metadata
|
||||||
|
metadata = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"model_name": MODEL_NAME,
|
||||||
|
"task": "sms-spam-classification",
|
||||||
|
"max_length": MAX_LENGTH,
|
||||||
|
"num_labels": 2,
|
||||||
|
"label2id": LABEL2ID,
|
||||||
|
"id2label": ID2LABEL,
|
||||||
|
"framework": "pytorch",
|
||||||
|
"export_format": "onnx",
|
||||||
|
}
|
||||||
|
metadata_path = onnx_dir / "model_metadata.json"
|
||||||
|
with open(metadata_path, "w") as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
print(f" Metadata saved to: {metadata_path}")
|
||||||
|
return onnx_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Train DistilBERT SMS Spam Classifier")
|
||||||
|
parser.add_argument("--output-dir", type=str, default="./output", help="Output directory")
|
||||||
|
parser.add_argument("--epochs", type=int, default=NUM_EPOCHS, help="Number of training epochs")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=BATCH_SIZE, help="Batch size")
|
||||||
|
parser.add_argument("--lr", type=float, default=LEARNING_RATE, help="Learning rate")
|
||||||
|
parser.add_argument("--quantize", action="store_true", help="Quantize to INT8")
|
||||||
|
parser.add_argument("--skip-training", action="store_true", help="Skip training, only export")
|
||||||
|
parser.add_argument("--augment", action="store_true", help="Include synthetic data augmentation")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
output_dir = Path(args.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("DistilBERT SMS Spam Classifier Training Pipeline")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Step 1: Load dataset
|
||||||
|
print("\n--- Step 1: Loading Dataset ---")
|
||||||
|
dataset_dict = load_dataset()
|
||||||
|
|
||||||
|
# Step 2: Optional synthetic augmentation
|
||||||
|
if args.augment:
|
||||||
|
print("\n--- Step 2: Data Augmentation ---")
|
||||||
|
synthetic = generate_synthetic_data(num_samples=1000)
|
||||||
|
synthetic_ds = Dataset.from_dict(synthetic)
|
||||||
|
# Cast label to match train dataset's label type
|
||||||
|
train_ds = dataset_dict["train"]
|
||||||
|
synthetic_ds = synthetic_ds.cast_column("label", train_ds.features["label"])
|
||||||
|
augmented = concatenate_datasets([train_ds, synthetic_ds])
|
||||||
|
dataset_dict["train"] = augmented.shuffle(seed=42)
|
||||||
|
print(f" Augmented train set: {len(dataset_dict['train'])} samples")
|
||||||
|
|
||||||
|
# Step 3: Train model
|
||||||
|
model = None
|
||||||
|
tokenizer = None
|
||||||
|
val_acc = 0.0
|
||||||
|
if not args.skip_training:
|
||||||
|
print("\n--- Step 3: Training ---")
|
||||||
|
model, tokenizer, val_acc = train_model(
|
||||||
|
train_dataset=dataset_dict["train"],
|
||||||
|
val_dataset=dataset_dict["val"],
|
||||||
|
output_dir=output_dir,
|
||||||
|
epochs=args.epochs,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
learning_rate=args.lr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if val_acc < 0.95:
|
||||||
|
print(f"\n WARNING: Validation accuracy {val_acc:.4f} is below 0.95 target")
|
||||||
|
print(" Consider increasing epochs or adjusting hyperparameters")
|
||||||
|
else:
|
||||||
|
print("\n--- Step 3: Skipping training (--skip-training) ---")
|
||||||
|
# Load existing model for export
|
||||||
|
final_model_path = output_dir / "final_model"
|
||||||
|
if final_model_path.exists():
|
||||||
|
print(f" Loading model from {final_model_path}")
|
||||||
|
model = DistilBertForSequenceClassification.from_pretrained(
|
||||||
|
str(final_model_path), num_labels=2
|
||||||
|
)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(str(final_model_path))
|
||||||
|
else:
|
||||||
|
print(f" No existing model found at {final_model_path}")
|
||||||
|
print(f" Loading base {MODEL_NAME} for export...")
|
||||||
|
model = DistilBertForSequenceClassification.from_pretrained(
|
||||||
|
MODEL_NAME, num_labels=2
|
||||||
|
)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
||||||
|
|
||||||
|
# Step 4: Export to ONNX
|
||||||
|
print("\n--- Step 4: ONNX Export ---")
|
||||||
|
onnx_dir = export_onnx(model, tokenizer, output_dir, quantize=args.quantize)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Pipeline complete!")
|
||||||
|
print(f" ONNX model: {onnx_dir / 'model.onnx'}")
|
||||||
|
print(f" Tokenizer: {onnx_dir}")
|
||||||
|
print(f" Validation accuracy: {val_acc:.4f}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
203
pnpm-lock.yaml
generated
203
pnpm-lock.yaml
generated
@@ -105,6 +105,9 @@ importers:
|
|||||||
firebase-admin:
|
firebase-admin:
|
||||||
specifier: ^13.10.0
|
specifier: ^13.10.0
|
||||||
version: 13.10.0
|
version: 13.10.0
|
||||||
|
imapflow:
|
||||||
|
specifier: ^1.3.4
|
||||||
|
version: 1.3.4
|
||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.10.1
|
specifier: ^5.10.1
|
||||||
version: 5.10.1
|
version: 5.10.1
|
||||||
@@ -117,6 +120,9 @@ importers:
|
|||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
onnxruntime-node:
|
||||||
|
specifier: ^1.26.0
|
||||||
|
version: 1.26.0
|
||||||
pino:
|
pino:
|
||||||
specifier: ^10.3.1
|
specifier: ^10.3.1
|
||||||
version: 10.3.1
|
version: 10.3.1
|
||||||
@@ -169,6 +175,9 @@ importers:
|
|||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^29.1.1
|
specifier: ^29.1.1
|
||||||
version: 29.1.1
|
version: 29.1.1
|
||||||
|
playwright:
|
||||||
|
specifier: ^1.60.0
|
||||||
|
version: 1.60.0
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.22.3
|
specifier: ^4.22.3
|
||||||
version: 4.22.3
|
version: 4.22.3
|
||||||
@@ -2203,6 +2212,9 @@ packages:
|
|||||||
'@vitest/utils@4.1.7':
|
'@vitest/utils@4.1.7':
|
||||||
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
|
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
|
||||||
|
|
||||||
|
'@zone-eu/mailsplit@5.4.12':
|
||||||
|
resolution: {integrity: sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==}
|
||||||
|
|
||||||
abbrev@3.0.1:
|
abbrev@3.0.1:
|
||||||
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
|
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
@@ -2221,6 +2233,10 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adm-zip@0.5.17:
|
||||||
|
resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==}
|
||||||
|
engines: {node: '>=12.0'}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
@@ -2676,10 +2692,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
define-data-property@1.1.4:
|
||||||
|
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
define-lazy-prop@3.0.0:
|
define-lazy-prop@3.0.0:
|
||||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
define-properties@1.2.1:
|
||||||
|
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
defu@6.1.7:
|
defu@6.1.7:
|
||||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||||
|
|
||||||
@@ -2872,6 +2896,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
encoding-japanese@2.2.0:
|
||||||
|
resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==}
|
||||||
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
end-of-stream@1.4.5:
|
end-of-stream@1.4.5:
|
||||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||||
|
|
||||||
@@ -2949,6 +2977,10 @@ packages:
|
|||||||
escape-html@1.0.3:
|
escape-html@1.0.3:
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||||
|
|
||||||
|
escape-string-regexp@4.0.0:
|
||||||
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
escape-string-regexp@5.0.0:
|
escape-string-regexp@5.0.0:
|
||||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3159,6 +3191,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
|
global-agent@4.1.3:
|
||||||
|
resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==}
|
||||||
|
engines: {node: '>=10.0'}
|
||||||
|
|
||||||
|
globalthis@1.0.4:
|
||||||
|
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
globby@16.2.0:
|
globby@16.2.0:
|
||||||
resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==}
|
resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3214,6 +3254,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
has-property-descriptors@1.0.2:
|
||||||
|
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
||||||
|
|
||||||
has-symbols@1.1.0:
|
has-symbols@1.1.0:
|
||||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3283,6 +3326,10 @@ packages:
|
|||||||
httpxy@0.5.3:
|
httpxy@0.5.3:
|
||||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||||
|
|
||||||
|
iconv-lite@0.7.2:
|
||||||
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
@@ -3290,6 +3337,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
imapflow@1.3.4:
|
||||||
|
resolution: {integrity: sha512-pYWCH5KUv7hk4Dhduhmel/uYNQzxYMnc+TE7VGNy9E4sztzHhEo+T4cy/cB+bZFrfZI9uqpp/iulkV4ubBOhtA==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3305,6 +3355,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
|
ip-address@10.2.0:
|
||||||
|
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
iron-webcrypto@1.2.1:
|
iron-webcrypto@1.2.1:
|
||||||
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
||||||
|
|
||||||
@@ -3491,6 +3545,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||||
engines: {node: '>= 0.6.3'}
|
engines: {node: '>= 0.6.3'}
|
||||||
|
|
||||||
|
libbase64@1.3.0:
|
||||||
|
resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==}
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==}
|
||||||
|
|
||||||
|
libqp@2.1.1:
|
||||||
|
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
||||||
|
|
||||||
libsql@0.5.29:
|
libsql@0.5.29:
|
||||||
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
|
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
|
||||||
cpu: [x64, arm64, wasm32, arm]
|
cpu: [x64, arm64, wasm32, arm]
|
||||||
@@ -3661,6 +3724,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
|
resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
matcher@4.0.0:
|
||||||
|
resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3837,6 +3904,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@8.0.10:
|
||||||
|
resolution: {integrity: sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
nopt@8.1.0:
|
nopt@8.1.0:
|
||||||
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
@@ -3854,6 +3925,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
object-keys@1.1.1:
|
||||||
|
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
obug@2.1.1:
|
obug@2.1.1:
|
||||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||||
|
|
||||||
@@ -3877,6 +3952,13 @@ packages:
|
|||||||
oniguruma-to-es@2.3.0:
|
oniguruma-to-es@2.3.0:
|
||||||
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
||||||
|
|
||||||
|
onnxruntime-common@1.26.0:
|
||||||
|
resolution: {integrity: sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==}
|
||||||
|
|
||||||
|
onnxruntime-node@1.26.0:
|
||||||
|
resolution: {integrity: sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==}
|
||||||
|
os: [win32, darwin, linux]
|
||||||
|
|
||||||
open@11.0.0:
|
open@11.0.0:
|
||||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -4258,6 +4340,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
safer-buffer@2.1.2:
|
||||||
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
@@ -4290,6 +4375,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
serialize-error@8.1.0:
|
||||||
|
resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
serialize-javascript@7.0.5:
|
serialize-javascript@7.0.5:
|
||||||
resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==}
|
resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -4352,6 +4441,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
smart-buffer@4.2.0:
|
||||||
|
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||||
|
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
smob@1.6.2:
|
smob@1.6.2:
|
||||||
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
|
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -4363,6 +4456,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==}
|
resolution: {integrity: sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
socks@2.8.9:
|
||||||
|
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||||
|
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
solid-js@1.9.13:
|
solid-js@1.9.13:
|
||||||
resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==}
|
resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==}
|
||||||
|
|
||||||
@@ -4634,6 +4731,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RN3TZxUtxLz2HBZVt62+LdZxQbrMVgYKtuzLgwmO7nqKvR+gQS5mCackD9hf4Y7MmoK/bX7tCm7kaJC8kC8zFA==}
|
resolution: {integrity: sha512-RN3TZxUtxLz2HBZVt62+LdZxQbrMVgYKtuzLgwmO7nqKvR+gQS5mCackD9hf4Y7MmoK/bX7tCm7kaJC8kC8zFA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
type-fest@0.20.2:
|
||||||
|
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
type-fest@4.41.0:
|
type-fest@4.41.0:
|
||||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -6916,7 +7017,7 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 4.1.0
|
std-env: 4.1.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))
|
vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))
|
||||||
|
|
||||||
'@vitest/expect@4.1.7':
|
'@vitest/expect@4.1.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6967,6 +7068,12 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@zone-eu/mailsplit@5.4.12':
|
||||||
|
dependencies:
|
||||||
|
libbase64: 1.3.0
|
||||||
|
libmime: 5.3.8
|
||||||
|
libqp: 2.1.1
|
||||||
|
|
||||||
abbrev@3.0.1: {}
|
abbrev@3.0.1: {}
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
@@ -6979,6 +7086,8 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
|
adm-zip@0.5.17: {}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -7402,8 +7511,20 @@ snapshots:
|
|||||||
bundle-name: 4.1.0
|
bundle-name: 4.1.0
|
||||||
default-browser-id: 5.0.1
|
default-browser-id: 5.0.1
|
||||||
|
|
||||||
|
define-data-property@1.1.4:
|
||||||
|
dependencies:
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
es-errors: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
|
||||||
define-lazy-prop@3.0.0: {}
|
define-lazy-prop@3.0.0: {}
|
||||||
|
|
||||||
|
define-properties@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
define-data-property: 1.1.4
|
||||||
|
has-property-descriptors: 1.0.2
|
||||||
|
object-keys: 1.1.1
|
||||||
|
|
||||||
defu@6.1.7: {}
|
defu@6.1.7: {}
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
@@ -7495,6 +7616,8 @@ snapshots:
|
|||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
|
|
||||||
|
encoding-japanese@2.2.0: {}
|
||||||
|
|
||||||
end-of-stream@1.4.5:
|
end-of-stream@1.4.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
@@ -7655,6 +7778,8 @@ snapshots:
|
|||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
escape-string-regexp@5.0.0: {}
|
escape-string-regexp@5.0.0: {}
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
@@ -7896,6 +8021,18 @@ snapshots:
|
|||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
path-scurry: 2.0.2
|
path-scurry: 2.0.2
|
||||||
|
|
||||||
|
global-agent@4.1.3:
|
||||||
|
dependencies:
|
||||||
|
globalthis: 1.0.4
|
||||||
|
matcher: 4.0.0
|
||||||
|
semver: 7.8.1
|
||||||
|
serialize-error: 8.1.0
|
||||||
|
|
||||||
|
globalthis@1.0.4:
|
||||||
|
dependencies:
|
||||||
|
define-properties: 1.2.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
|
||||||
globby@16.2.0:
|
globby@16.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sindresorhus/merge-streams': 4.0.0
|
'@sindresorhus/merge-streams': 4.0.0
|
||||||
@@ -7991,6 +8128,10 @@ snapshots:
|
|||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
|
has-property-descriptors@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
has-tostringtag@1.0.2:
|
has-tostringtag@1.0.2:
|
||||||
@@ -8077,10 +8218,26 @@ snapshots:
|
|||||||
|
|
||||||
httpxy@0.5.3: {}
|
httpxy@0.5.3: {}
|
||||||
|
|
||||||
|
iconv-lite@0.7.2:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
imapflow@1.3.4:
|
||||||
|
dependencies:
|
||||||
|
'@zone-eu/mailsplit': 5.4.12
|
||||||
|
encoding-japanese: 2.2.0
|
||||||
|
iconv-lite: 0.7.2
|
||||||
|
libbase64: 1.3.0
|
||||||
|
libmime: 5.3.8
|
||||||
|
libqp: 2.1.1
|
||||||
|
nodemailer: 8.0.10
|
||||||
|
pino: 10.3.1
|
||||||
|
socks: 2.8.9
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -8109,6 +8266,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
ip-address@10.2.0: {}
|
||||||
|
|
||||||
iron-webcrypto@1.2.1: {}
|
iron-webcrypto@1.2.1: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
@@ -8290,6 +8449,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
|
|
||||||
|
libbase64@1.3.0: {}
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: 2.2.0
|
||||||
|
iconv-lite: 0.7.2
|
||||||
|
libbase64: 1.3.0
|
||||||
|
libqp: 2.1.1
|
||||||
|
|
||||||
|
libqp@2.1.1: {}
|
||||||
|
|
||||||
libsql@0.5.29:
|
libsql@0.5.29:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@neon-rs/load': 0.0.4
|
'@neon-rs/load': 0.0.4
|
||||||
@@ -8456,6 +8626,10 @@ snapshots:
|
|||||||
|
|
||||||
map-obj@4.3.0: {}
|
map-obj@4.3.0: {}
|
||||||
|
|
||||||
|
matcher@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
escape-string-regexp: 4.0.0
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mdast-util-to-hast@13.2.1:
|
mdast-util-to-hast@13.2.1:
|
||||||
@@ -8712,6 +8886,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.46: {}
|
node-releases@2.0.46: {}
|
||||||
|
|
||||||
|
nodemailer@8.0.10: {}
|
||||||
|
|
||||||
nopt@8.1.0:
|
nopt@8.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
abbrev: 3.0.1
|
abbrev: 3.0.1
|
||||||
@@ -8723,6 +8899,8 @@ snapshots:
|
|||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
|
object-keys@1.1.1: {}
|
||||||
|
|
||||||
obug@2.1.1: {}
|
obug@2.1.1: {}
|
||||||
|
|
||||||
ofetch@1.5.1:
|
ofetch@1.5.1:
|
||||||
@@ -8749,6 +8927,14 @@ snapshots:
|
|||||||
regex: 5.1.1
|
regex: 5.1.1
|
||||||
regex-recursion: 5.1.1
|
regex-recursion: 5.1.1
|
||||||
|
|
||||||
|
onnxruntime-common@1.26.0: {}
|
||||||
|
|
||||||
|
onnxruntime-node@1.26.0:
|
||||||
|
dependencies:
|
||||||
|
adm-zip: 0.5.17
|
||||||
|
global-agent: 4.1.3
|
||||||
|
onnxruntime-common: 1.26.0
|
||||||
|
|
||||||
open@11.0.0:
|
open@11.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
default-browser: 5.5.0
|
default-browser: 5.5.0
|
||||||
@@ -9197,6 +9383,8 @@ snapshots:
|
|||||||
|
|
||||||
safe-stable-stringify@2.5.0: {}
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
@@ -9229,6 +9417,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
serialize-error@8.1.0:
|
||||||
|
dependencies:
|
||||||
|
type-fest: 0.20.2
|
||||||
|
|
||||||
serialize-javascript@7.0.5: {}
|
serialize-javascript@7.0.5: {}
|
||||||
|
|
||||||
seroval-plugins@1.5.4(seroval@1.5.4):
|
seroval-plugins@1.5.4(seroval@1.5.4):
|
||||||
@@ -9303,6 +9495,8 @@ snapshots:
|
|||||||
|
|
||||||
slash@5.1.0: {}
|
slash@5.1.0: {}
|
||||||
|
|
||||||
|
smart-buffer@4.2.0: {}
|
||||||
|
|
||||||
smob@1.6.2: {}
|
smob@1.6.2: {}
|
||||||
|
|
||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
@@ -9316,6 +9510,11 @@ snapshots:
|
|||||||
snake-case: 3.0.4
|
snake-case: 3.0.4
|
||||||
type-fest: 4.41.0
|
type-fest: 4.41.0
|
||||||
|
|
||||||
|
socks@2.8.9:
|
||||||
|
dependencies:
|
||||||
|
ip-address: 10.2.0
|
||||||
|
smart-buffer: 4.2.0
|
||||||
|
|
||||||
solid-js@1.9.13:
|
solid-js@1.9.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
@@ -9620,6 +9819,8 @@ snapshots:
|
|||||||
- debug
|
- debug
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
type-fest@0.20.2: {}
|
||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
type-fest@5.6.0:
|
type-fest@5.6.0:
|
||||||
|
|||||||
@@ -7,30 +7,31 @@
|
|||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
### Phase 1 — Foundation (Revenue Enabler)
|
### Phase 1 — Foundation (Revenue Enabler)
|
||||||
- [ ] 01 — Stripe Checkout, webhooks, and subscription state management → `01-stripe-checkout-webhooks.md`
|
- [x] 01 — Stripe Checkout, webhooks, and subscription state management → `01-stripe-checkout-webhooks.md`
|
||||||
- [ ] 02 — Automated removal engine for top 20 data brokers → `02-removebrokers-top-20.md`
|
- [x] 02 — Automated removal engine for top 20 data brokers → `02-removebrokers-top-20.md`
|
||||||
|
|
||||||
### Phase 2 — Core Services (Table Stakes)
|
### Phase 2 — Core Services (Table Stakes)
|
||||||
- [ ] 03 — HIBP API integration for email breach monitoring → `03-darkwatch-hibp.md`
|
- [x] 03 — HIBP API integration for email breach monitoring → `03-darkwatch-hibp.md`
|
||||||
- [ ] 04 — SecurityTrails, Censys, Shodan API integrations → `04-darkwatch-attack-surface.md`
|
- [x] 04 — SecurityTrails, Censys, Shodan API integrations → `04-darkwatch-attack-surface.md`
|
||||||
- [ ] 05 — Periodic scan scheduling, WebSocket progress, alert deduplication → `05-darkwatch-scheduler.md`
|
- [x] 05 — Periodic scan scheduling, WebSocket progress, alert deduplication → `05-darkwatch-scheduler.md`
|
||||||
- [ ] 06 — Twilio Lookup and phone reputation API integration → `06-spamshield-reputation.md`
|
- [x] 06 — Twilio Lookup and phone reputation API integration → `06-spamshield-reputation.md`
|
||||||
- [ ] 07 — Fine-tuned DistilBERT SMS spam classifier with ONNX deployment → `07-spamshield-ml-classifier.md`
|
- [x] 07 — Fine-tuned DistilBERT SMS spam classifier with ONNX deployment → `07-spamshield-ml-classifier.md`
|
||||||
|
|
||||||
### Phase 3 — Scale & Expand
|
### Phase 3 — Scale & Expand
|
||||||
- [ ] 08 — Expand broker coverage to 50+ with CAPTCHA solving → `08-removebrokers-50-plus.md`
|
- [x] 08 — Expand broker coverage to 50+ with CAPTCHA solving → `08-removebrokers-50-plus.md`
|
||||||
- [ ] 09 — Attom Data Solutions API for property record snapshots → `09-hometitle-attom-api.md`
|
- [x] 09 — Attom Data Solutions API for property record snapshots → `09-hometitle-attom-api.md`
|
||||||
- [ ] 10 — County recorder web scrapers for top 100 US counties → `10-hometitle-county-scrapers.md`
|
- [x] 10 — County recorder web scrapers for top 100 US counties → `10-hometitle-county-scrapers.md`
|
||||||
- [ ] 11 — Azure Voice Live API for synthetic voice detection → `11-voiceprint-azure-api.md`
|
- [x] 11 — Azure Voice Live API for synthetic voice detection → `11-voiceprint-azure-api.md`
|
||||||
|
|
||||||
### Phase 4 — Differentiation & Polish
|
### Phase 4 — Differentiation & Polish
|
||||||
- [ ] 12 — iOS CallKit and Android Telecom API for real-time call analysis → `12-voiceprint-mobile-integration.md`
|
- [x] 12 — iOS CallKit and Android Telecom API for real-time call analysis → `12-voiceprint-mobile-integration.md`
|
||||||
- [ ] 13 — Cross-service threat correlation scoring and unified alert feed → `13-correlation-engine.md`
|
- [x] 13 — Cross-service threat correlation scoring and unified alert feed → `13-correlation-engine.md`
|
||||||
- [ ] 14 — Family plan member management, billing proration, multi-user dashboard → `14-family-plans.md`
|
- [x] 14 — Family plan member management, billing proration, multi-user dashboard → `14-family-plans.md`
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- 02 → 08 (expand broker automation after initial 20 work)
|
- 02 → 08 (expand broker automation after initial 20 work)
|
||||||
- 03 → 04 → 05 (HIBP before attack surface APIs before scheduling)
|
- 03 → 04 (HIBP before attack surface APIs before scheduling)
|
||||||
|
- 04 → 05 (HIBP before attack surface APIs before scheduling)
|
||||||
- 06 → 07 (reputation APIs before ML classifier)
|
- 06 → 07 (reputation APIs before ML classifier)
|
||||||
- 09 → 10 (Attom API before county scraping fallback)
|
- 09 → 10 (Attom API before county scraping fallback)
|
||||||
- 11 → 12 (Azure API before mobile integration)
|
- 11 → 12 (Azure API before mobile integration)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||||
"db:seed": "tsx src/server/db/seed.ts"
|
"db:seed": "tsx src/server/db/seed.ts",
|
||||||
|
"benchmark:spamshield": "tsx src/server/services/spamshield/benchmark.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.15.0",
|
"@libsql/client": "^0.15.0",
|
||||||
@@ -32,10 +33,12 @@
|
|||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"firebase-admin": "^13.10.0",
|
"firebase-admin": "^13.10.0",
|
||||||
|
"imapflow": "^1.3.4",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"isomorphic-dompurify": "^3.15.0",
|
"isomorphic-dompurify": "^3.15.0",
|
||||||
"jose": "^5",
|
"jose": "^5",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"onnxruntime-node": "^1.26.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"puppeteer": "^25.0.4",
|
"puppeteer": "^25.0.4",
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"tsx": "^4.22.3",
|
"tsx": "^4.22.3",
|
||||||
"vite-plugin-solid": "^2.11.12",
|
"vite-plugin-solid": "^2.11.12",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
|
|||||||
@@ -54,6 +54,33 @@ function SeverityIcon(props: { severity: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlation narrative banner showing grouped alerts.
|
||||||
|
*/
|
||||||
|
function CorrelationNarrative(props: { narrative: string; alertCount: number; severity: string }) {
|
||||||
|
return (
|
||||||
|
<div class="px-6 py-3 -mx-6 bg-[var(--color-bg-secondary)]/50 border-y border-[var(--color-border)]/50">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0 mt-0.5 text-[var(--color-warning)]">
|
||||||
|
<path d="M8 1l7 4v6a1 1 0 01-.5.9l-6 3.5-6-3.5A1 1 0 012 11V5l6-4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" />
|
||||||
|
<path d="M8 5v3M8 9.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-xs font-semibold text-[var(--color-warning)] uppercase tracking-wide">
|
||||||
|
Correlated Attack
|
||||||
|
</span>
|
||||||
|
<Badge variant="warning">{props.alertCount} related events</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--color-text-secondary)] leading-relaxed">
|
||||||
|
{props.narrative}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AlertFeedWidget(props: AlertFeedWidgetProps) {
|
export default function AlertFeedWidget(props: AlertFeedWidgetProps) {
|
||||||
const [tick, setTick] = createSignal(0);
|
const [tick, setTick] = createSignal(0);
|
||||||
const [resolving, setResolving] = createSignal<Record<string, boolean>>({});
|
const [resolving, setResolving] = createSignal<Record<string, boolean>>({});
|
||||||
@@ -63,11 +90,18 @@ export default function AlertFeedWidget(props: AlertFeedWidgetProps) {
|
|||||||
onCleanup(() => clearInterval(interval));
|
onCleanup(() => clearInterval(interval));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load alerts
|
||||||
const [alerts, { refetch }] = createResource(tick, () =>
|
const [alerts, { refetch }] = createResource(tick, () =>
|
||||||
api.correlation.getAlerts.query({ limit: 10 }),
|
api.correlation.getAlerts.query({ limit: 10 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load correlation groups for narrative display
|
||||||
|
const [groups] = createResource(tick, () =>
|
||||||
|
api.correlation.getGroups.query({ status: "ACTIVE", limit: 5 }),
|
||||||
|
);
|
||||||
|
|
||||||
const items = () => alerts()?.items ?? [];
|
const items = () => alerts()?.items ?? [];
|
||||||
|
const activeGroups = () => groups()?.items ?? [];
|
||||||
|
|
||||||
const handleMarkRead = async (alertId: string) => {
|
const handleMarkRead = async (alertId: string) => {
|
||||||
setResolving((prev) => ({ ...prev, [alertId]: true }));
|
setResolving((prev) => ({ ...prev, [alertId]: true }));
|
||||||
@@ -93,6 +127,18 @@ export default function AlertFeedWidget(props: AlertFeedWidgetProps) {
|
|||||||
>
|
>
|
||||||
<Show when={alerts.loading && !alerts()} fallback={
|
<Show when={alerts.loading && !alerts()} fallback={
|
||||||
<div class="divide-y divide-[var(--color-border)]/50 -mx-6 -mb-4">
|
<div class="divide-y divide-[var(--color-border)]/50 -mx-6 -mb-4">
|
||||||
|
{/* Correlation narratives */}
|
||||||
|
<For each={activeGroups().filter((g: any) => g.narrative)}>
|
||||||
|
{(group: any) => (
|
||||||
|
<CorrelationNarrative
|
||||||
|
narrative={group.narrative}
|
||||||
|
alertCount={group.alertCount ?? 0}
|
||||||
|
severity={group.highestSeverity ?? "WARNING"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
{/* Individual alerts */}
|
||||||
<For each={items()}>
|
<For each={items()}>
|
||||||
{(alert) => {
|
{(alert) => {
|
||||||
const severity = String(alert.severity ?? "INFO");
|
const severity = String(alert.severity ?? "INFO");
|
||||||
@@ -115,6 +161,11 @@ export default function AlertFeedWidget(props: AlertFeedWidgetProps) {
|
|||||||
{String(alert.source)}
|
{String(alert.source)}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={alert.groupId}>
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-[var(--color-warning)]/10 text-[var(--color-warning)] text-[10px]">
|
||||||
|
correlated
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 flex gap-1">
|
<div class="flex-shrink-0 flex gap-1">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function RemoveBrokersWidget(props: RemoveBrokersWidgetProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [stats] = createResource(tick, () =>
|
const [stats] = createResource(tick, () =>
|
||||||
api.removebrokers.getStats.query(),
|
api.removebrokers.getEnhancedStats.query(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [registry] = createResource(tick, () =>
|
const [registry] = createResource(tick, () =>
|
||||||
@@ -25,14 +25,17 @@ export default function RemoveBrokersWidget(props: RemoveBrokersWidgetProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const totalBrokers = () => registry()?.length ?? 0;
|
const totalBrokers = () => registry()?.length ?? 0;
|
||||||
const totalRequests = () => stats()?.total ?? 0;
|
const totalRequests = () => (stats() as Record<string, unknown>)?.total ?? 0;
|
||||||
const pending = () => {
|
const pending = () => {
|
||||||
const s = stats();
|
const s = stats() as Record<string, unknown>;
|
||||||
if (!s) return 0;
|
if (!s) return 0;
|
||||||
return (s.byStatus?.PENDING ?? 0) + (s.byStatus?.SUBMITTED ?? 0) + (s.byStatus?.IN_PROGRESS ?? 0);
|
return (s.pending ?? 0) as number;
|
||||||
};
|
};
|
||||||
const completed = () => stats()?.byStatus?.COMPLETED ?? 0;
|
const completed = () => (stats() as Record<string, unknown>)?.completed ?? 0;
|
||||||
const completionRate = () => stats()?.completionRate ?? 0;
|
const progress = () => (stats() as Record<string, unknown>)?.progress ?? "0 of 0 brokers completed";
|
||||||
|
const completionRate = () => (stats() as Record<string, unknown>)?.completionRate ?? 0;
|
||||||
|
const systemHealth = () => (stats() as Record<string, unknown>)?.systemHealth as Record<string, unknown> | undefined;
|
||||||
|
const healthPct = () => (systemHealth()?.systemHealthPercentage ?? 100) as number;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -52,7 +55,7 @@ export default function RemoveBrokersWidget(props: RemoveBrokersWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-4 gap-2">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{totalBrokers()}</div>
|
<div class="text-2xl font-bold text-[var(--color-text-primary)]">{totalBrokers()}</div>
|
||||||
<div class="text-xs text-[var(--color-text-tertiary)]">Brokers</div>
|
<div class="text-xs text-[var(--color-text-tertiary)]">Brokers</div>
|
||||||
@@ -65,12 +68,16 @@ export default function RemoveBrokersWidget(props: RemoveBrokersWidgetProps) {
|
|||||||
<div class="text-2xl font-bold text-[var(--color-success)]">{completed()}</div>
|
<div class="text-2xl font-bold text-[var(--color-success)]">{completed()}</div>
|
||||||
<div class="text-xs text-[var(--color-text-tertiary)]">Completed</div>
|
<div class="text-xs text-[var(--color-text-tertiary)]">Completed</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-[var(--color-text-tertiary)]">{healthPct()}%</div>
|
||||||
|
<div class="text-xs text-[var(--color-text-tertiary)]">System Health</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="px-1">
|
||||||
<div class="flex items-center justify-between text-xs text-[var(--color-text-secondary)] mb-1">
|
<div class="flex items-center justify-between text-xs text-[var(--color-text-secondary)] mb-1">
|
||||||
<span>Removal Progress</span>
|
<span>Removal Progress</span>
|
||||||
<span>{completionRate()}%</span>
|
<span>{String(progress())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2.5 rounded-full bg-[var(--color-bg-secondary)] overflow-hidden">
|
<div class="h-2.5 rounded-full bg-[var(--color-bg-secondary)] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createResource, createSignal, onMount, onCleanup, Show } from "solid-js";
|
import { createResource, createSignal, onMount, onCleanup, Show, For } from "solid-js";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import Card from "~/components/ui/Card";
|
import Card from "~/components/ui/Card";
|
||||||
@@ -11,14 +11,60 @@ const CIRCUMFERENCE = 2 * Math.PI * 45;
|
|||||||
|
|
||||||
function scoreColor(score: number): string {
|
function scoreColor(score: number): string {
|
||||||
if (score <= 30) return "var(--color-success)";
|
if (score <= 30) return "var(--color-success)";
|
||||||
if (score <= 70) return "var(--color-warning)";
|
if (score <= 60) return "var(--color-warning)";
|
||||||
return "var(--color-error)";
|
return "var(--color-error)";
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreLabel(score: number): string {
|
function scoreLabel(score: number): string {
|
||||||
if (score <= 30) return "Low";
|
if (score <= 30) return "Low Risk";
|
||||||
if (score <= 70) return "Medium";
|
if (score <= 60) return "Medium Risk";
|
||||||
return "High";
|
return "High Risk";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini sparkline SVG for 90-day trend.
|
||||||
|
*/
|
||||||
|
function TrendSparkline(props: { data: Array<{ date: string; score: number }> }) {
|
||||||
|
const width = 140;
|
||||||
|
const height = 32;
|
||||||
|
const padding = 2;
|
||||||
|
|
||||||
|
const points = () => {
|
||||||
|
const data = props.data;
|
||||||
|
if (data.length < 2) return "";
|
||||||
|
|
||||||
|
const scores = data.map(d => d.score);
|
||||||
|
const minScore = Math.min(...scores, 0);
|
||||||
|
const maxScore = Math.max(...scores, 100);
|
||||||
|
const range = maxScore - minScore || 1;
|
||||||
|
|
||||||
|
return data
|
||||||
|
.map((d, i) => {
|
||||||
|
const x = padding + (i / (data.length - 1)) * (width - padding * 2);
|
||||||
|
const y = height - padding - ((d.score - minScore) / range) * (height - padding * 2);
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const color = () => {
|
||||||
|
const last = props.data[props.data.length - 1];
|
||||||
|
return last ? scoreColor(last.score) : "var(--color-text-tertiary)";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke={color()}
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
points={points()}
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
|
export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
|
||||||
@@ -30,8 +76,15 @@ export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
|
|||||||
onCleanup(() => clearInterval(interval));
|
onCleanup(() => clearInterval(interval));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load stats for current score
|
||||||
const [stats] = createResource(tick, () => api.correlation.getStats.query());
|
const [stats] = createResource(tick, () => api.correlation.getStats.query());
|
||||||
|
|
||||||
|
// Load trend data for the sparkline
|
||||||
|
const [trendData] = createResource(() => api.correlation.getThreatScoreTrend.query());
|
||||||
|
|
||||||
|
// Load recommendations
|
||||||
|
const [recommendations] = createResource(() => api.correlation.getRecommendations.query());
|
||||||
|
|
||||||
const score = () => {
|
const score = () => {
|
||||||
const d = stats();
|
const d = stats();
|
||||||
if (!d) return 0;
|
if (!d) return 0;
|
||||||
@@ -48,17 +101,48 @@ export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
|
|||||||
return "stable";
|
return "stable";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trendChange = () => {
|
||||||
|
const data = trendData();
|
||||||
|
if (!data) return null;
|
||||||
|
return data.change;
|
||||||
|
};
|
||||||
|
|
||||||
const dashOffset = () => CIRCUMFERENCE * (1 - score() / 100);
|
const dashOffset = () => CIRCUMFERENCE * (1 - score() / 100);
|
||||||
const color = () => scoreColor(score());
|
const color = () => scoreColor(score());
|
||||||
const label = () => scoreLabel(score());
|
const label = () => scoreLabel(score());
|
||||||
|
|
||||||
|
const topRecommendations = () => {
|
||||||
|
const data = recommendations();
|
||||||
|
if (!data) return [];
|
||||||
|
// Show top 2 critical/high recommendations
|
||||||
|
return data.recommendations
|
||||||
|
.filter((r: { priority: string }) => r.priority === "critical" || r.priority === "high")
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
class={cn("hover:shadow-glow-primary/20 transition-shadow cursor-pointer", props.class)}
|
class={cn("hover:shadow-glow-primary/20 transition-shadow cursor-pointer", props.class)}
|
||||||
header={<span class="text-sm font-semibold text-[var(--color-text-primary)]">Threat Score</span>}
|
header={
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-semibold text-[var(--color-text-primary)]">Threat Score</span>
|
||||||
|
<Show when={trendData()?.threatLevel}>
|
||||||
|
<span class="text-xs font-medium px-2 py-0.5 rounded-full" style={{
|
||||||
|
background: trendData()!.threatLevel.color === "green" ? "var(--color-success)" :
|
||||||
|
trendData()!.threatLevel.color === "yellow" ? "var(--color-warning)" :
|
||||||
|
"var(--color-error)",
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.85,
|
||||||
|
}}>
|
||||||
|
{trendData()!.threatLevel.label}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show when={stats.loading && !stats()} fallback={
|
<Show when={stats.loading && !stats()} fallback={
|
||||||
<div class="flex flex-col items-center py-2">
|
<div class="flex flex-col items-center py-2">
|
||||||
|
{/* Score gauge */}
|
||||||
<svg width="140" height="140" viewBox="0 0 120 120">
|
<svg width="140" height="140" viewBox="0 0 120 120">
|
||||||
<circle cx="60" cy="60" r="45" fill="none" stroke="var(--color-bg-secondary)" stroke-width="8" />
|
<circle cx="60" cy="60" r="45" fill="none" stroke="var(--color-bg-secondary)" stroke-width="8" />
|
||||||
<circle
|
<circle
|
||||||
@@ -79,6 +163,8 @@ export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
|
|||||||
{label()}
|
{label()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* Trend indicator */}
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<Show when={trend() !== "stable"}>
|
<Show when={trend() !== "stable"}>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class={cn(
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class={cn(
|
||||||
@@ -91,13 +177,58 @@ export default function ThreatScoreWidget(props: ThreatScoreWidgetProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-xs text-[var(--color-text-tertiary)]">
|
<span class="text-xs text-[var(--color-text-tertiary)]">
|
||||||
{trend() === "up" ? "Increased" : trend() === "down" ? "Decreased" : "Stable"} vs last check
|
<Show when={trendChange() !== null} fallback={
|
||||||
|
trend() === "up" ? "Increased" : trend() === "down" ? "Decreased" : "Stable"
|
||||||
|
}>
|
||||||
|
{trendChange()! > 0 ? `+${trendChange()!}` : trendChange()!} from last check
|
||||||
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 90-day trend sparkline */}
|
||||||
|
<Show when={trendData()?.dataPoints && trendData()!.dataPoints.length > 1}>
|
||||||
|
<div class="mt-2">
|
||||||
|
<TrendSparkline data={trendData()!.dataPoints} />
|
||||||
|
<span class="text-[10px] text-[var(--color-text-tertiary)]">90-day trend</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Correlation info */}
|
||||||
|
<Show when={stats()?.correlationCount && stats()!.correlationCount > 0}>
|
||||||
|
<div class="mt-2 text-xs text-center text-[var(--color-text-tertiary)]">
|
||||||
|
{stats()!.correlationCount} cross-service correlation{stats()!.correlationCount > 1 ? "s" : ""} detected
|
||||||
|
<Show when={stats()?.correlationBonus && stats()!.correlationBonus > 0}>
|
||||||
|
{" "}(+{stats()!.correlationBonus} score)
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Top recommendations */}
|
||||||
|
<Show when={topRecommendations().length > 0}>
|
||||||
|
<div class="mt-2 w-full px-4">
|
||||||
|
<For each={topRecommendations()}>
|
||||||
|
{(rec: { priority: string; text: string }) => (
|
||||||
|
<div class={cn(
|
||||||
|
"text-xs py-1 border-t border-[var(--color-border)]/30 flex items-start gap-1.5",
|
||||||
|
rec.priority === "critical" ? "text-[var(--color-error)]" : "text-[var(--color-warning)]",
|
||||||
|
)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="flex-shrink-0 mt-0.5">
|
||||||
|
<path d="M6 1l5 9H1L6 1z" fill="currentColor" opacity="0.3" />
|
||||||
|
<path d="M6 1l5 9H1L6 1z" stroke="currentColor" stroke-width="1" stroke-linejoin="round" />
|
||||||
|
<path d="M6 5v2M6 8.5v.5" stroke="white" stroke-width="1" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{rec.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div class="flex flex-col items-center py-2">
|
<div class="flex flex-col items-center py-2">
|
||||||
<div class="w-[140px] h-[140px] rounded-full bg-[var(--color-bg-secondary)] animate-pulse" />
|
<div class="w-[140px] h-[140px] rounded-full bg-[var(--color-bg-secondary)] animate-pulse" />
|
||||||
|
<div class="w-[140px] h-[32px] mt-2 bg-[var(--color-bg-secondary)] animate-pulse" />
|
||||||
|
<div class="w-[200px] h-[24px] mt-2 bg-[var(--color-bg-secondary)] animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -14,6 +14,37 @@ function BrokerIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EnhancedStats = {
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
pending: number;
|
||||||
|
completed: number;
|
||||||
|
totalListings: number;
|
||||||
|
listingsRemoved: number;
|
||||||
|
completionRate: number;
|
||||||
|
progress: string;
|
||||||
|
brokerSuccessRates: Array<{
|
||||||
|
brokerId: string;
|
||||||
|
brokerName: string;
|
||||||
|
status: string;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
failureRate24h: number;
|
||||||
|
totalOps24h: number;
|
||||||
|
isAutoDisabled: boolean;
|
||||||
|
}>;
|
||||||
|
systemHealth: {
|
||||||
|
healthy: number;
|
||||||
|
degraded: number;
|
||||||
|
broken: number;
|
||||||
|
disabled: number;
|
||||||
|
total: number;
|
||||||
|
systemHealthPercentage: number;
|
||||||
|
needsAlert: boolean;
|
||||||
|
alertMessage?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function RemoveBrokersPage() {
|
export default function RemoveBrokersPage() {
|
||||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||||
const [brokers] = createResource(
|
const [brokers] = createResource(
|
||||||
@@ -23,18 +54,20 @@ export default function RemoveBrokersPage() {
|
|||||||
const [removalRequests, { refetch }] = createResource(
|
const [removalRequests, { refetch }] = createResource(
|
||||||
() => api.removebrokers.getRemovalRequests.query({ page: 1, limit: 20 }),
|
() => api.removebrokers.getRemovalRequests.query({ page: 1, limit: 20 }),
|
||||||
);
|
);
|
||||||
const [stats] = createResource(
|
const [enhancedStats] = createResource(
|
||||||
() => api.removebrokers.getStats.query(),
|
() => api.removebrokers.getEnhancedStats.query(),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function createRequest(brokerId: string) {
|
async function createRequest(brokerId: string) {
|
||||||
await api.removebrokers.createRemovalRequest.mutate({
|
await api.removebrokers.createRemovalRequest.mutate({
|
||||||
brokerId,
|
brokerId,
|
||||||
personalInfo: { name: "", email: "", phone: "", address: "" },
|
personalInfo: { fullName: "" },
|
||||||
});
|
});
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = () => enhancedStats() as EnhancedStats | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
|
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
|
||||||
<Title>RemoveBrokers — Kordant</Title>
|
<Title>RemoveBrokers — Kordant</Title>
|
||||||
@@ -43,30 +76,70 @@ export default function RemoveBrokersPage() {
|
|||||||
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
|
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
|
||||||
<main id="main-content" class="flex-1 overflow-y-auto p-6">
|
<main id="main-content" class="flex-1 overflow-y-auto p-6">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">RemoveBrokers</h1>
|
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||||
|
RemoveBrokers
|
||||||
|
<span class="text-sm font-normal text-[var(--color-text-tertiary)] ml-2">
|
||||||
|
{stats()?.progress ?? ""}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<Suspense fallback={<div class="grid grid-cols-3 gap-4 mb-6"><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /></div>}>
|
<Suspense fallback={<div class="grid grid-cols-4 gap-4 mb-6"><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /><SkeletonCard class="h-24" /></div>}>
|
||||||
<Show when={stats()}>
|
<Show when={stats()}>
|
||||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||||
<Card class="p-4 text-center">
|
<Card class="p-4 text-center">
|
||||||
<p class="text-2xl font-bold text-[var(--color-brand-primary)]">
|
<p class="text-2xl font-bold text-[var(--color-brand-primary)]">
|
||||||
{String((stats() as Record<string, unknown>)?.totalRequests ?? 0)}
|
{stats()?.total ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-[var(--color-text-tertiary)]">Total Requests</p>
|
<p class="text-xs text-[var(--color-text-tertiary)]">Total Requests</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="p-4 text-center">
|
<Card class="p-4 text-center">
|
||||||
<p class="text-2xl font-bold text-[var(--color-success)]">
|
<p class="text-2xl font-bold text-[var(--color-success)]">
|
||||||
{String((stats() as Record<string, unknown>)?.completedRequests ?? 0)}
|
{stats()?.completed ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-[var(--color-text-tertiary)]">Completed</p>
|
<p class="text-xs text-[var(--color-text-tertiary)]">Completed</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="p-4 text-center">
|
<Card class="p-4 text-center">
|
||||||
<p class="text-2xl font-bold text-[var(--color-warning)]">
|
<p class="text-2xl font-bold text-[var(--color-warning)]">
|
||||||
{String((stats() as Record<string, unknown>)?.pendingRequests ?? 0)}
|
{stats()?.pending ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-[var(--color-text-tertiary)]">Pending</p>
|
<p class="text-xs text-[var(--color-text-tertiary)]">Pending</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card class="p-4 text-center">
|
||||||
|
<p class="text-2xl font-bold">{stats()?.systemHealth.systemHealthPercentage ?? 100}%</p>
|
||||||
|
<p class="text-xs text-[var(--color-text-tertiary)]">System Health</p>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Per-broker success rates */}
|
||||||
|
<Show when={(stats()?.brokerSuccessRates?.length ?? 0) > 0}>
|
||||||
|
<Card class="mb-6">
|
||||||
|
<div class="px-4 py-3 border-b border-[var(--color-border)]/50">
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--color-text-primary)]">Per-Broker Success Rates</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-[var(--color-border)]/50">
|
||||||
|
<For each={stats()?.brokerSuccessRates?.slice(0, 10) ?? []}>
|
||||||
|
{(broker: EnhancedStats["brokerSuccessRates"][number]) => (
|
||||||
|
<div class="px-4 py-2 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[var(--color-text-primary)]">{broker.brokerName}</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{broker.successCount}s / {broker.failureCount}f
|
||||||
|
</span>
|
||||||
|
<span class={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
broker.status === "healthy" ? "text-green-500 bg-green-500/10" :
|
||||||
|
broker.status === "degraded" ? "text-yellow-500 bg-yellow-500/10" :
|
||||||
|
broker.status === "broken" || broker.isAutoDisabled ? "text-red-500 bg-red-500/10" :
|
||||||
|
"text-gray-500 bg-gray-500/10"
|
||||||
|
}`}>
|
||||||
|
{broker.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
@@ -83,11 +156,11 @@ export default function RemoveBrokersPage() {
|
|||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
<div class="divide-y divide-[var(--color-border)]/50">
|
<div class="divide-y divide-[var(--color-border)]/50">
|
||||||
<For each={brokers()}>
|
<For each={brokers() as Array<Record<string, unknown>>}>
|
||||||
{(broker: Record<string, unknown>) => (
|
{(broker) => (
|
||||||
<div class="px-4 py-3 flex items-center justify-between">
|
<div class="px-4 py-3 flex items-center justify-between">
|
||||||
<p class="text-sm text-[var(--color-text-primary)]">{String(broker.name ?? "")}</p>
|
<p class="text-sm text-[var(--color-text-primary)]">{String(broker.name ?? "")}</p>
|
||||||
<Button size="sm" onClick={() => createRequest(String(broker.id))}>
|
<Button size="sm" onClick={() => createRequest(String((broker as Record<string, unknown>).id ?? ""))}>
|
||||||
Opt Out
|
Opt Out
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createSignal, createResource } from "solid-js";
|
import { createSignal, Show } from "solid-js";
|
||||||
import { Title } from "@solidjs/meta";
|
import { Title } from "@solidjs/meta";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
import { Sidebar, TopBar } from "~/components/dashboard";
|
import { Sidebar, TopBar } from "~/components/dashboard";
|
||||||
import { Button, Card, Input } from "~/components/ui";
|
import { Button, Card, Input, Badge } from "~/components/ui";
|
||||||
import { useAuth, useSubscription } from "~/hooks";
|
import { useAuth, useSubscription } from "~/hooks";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ export default function SettingsPage() {
|
|||||||
const subscription = useSubscription();
|
const subscription = useSubscription();
|
||||||
const [name, setName] = createSignal(auth.user()?.name ?? "");
|
const [name, setName] = createSignal(auth.user()?.name ?? "");
|
||||||
const [saving, setSaving] = createSignal(false);
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
const [portalLoading, setPortalLoading] = createSignal(false);
|
||||||
|
const [cancelLoading, setCancelLoading] = createSignal(false);
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -21,6 +24,45 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBillingPortal() {
|
||||||
|
setPortalLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.billing.createPortalSession.mutate({
|
||||||
|
returnUrl: `${window.location.origin}/settings`,
|
||||||
|
});
|
||||||
|
window.location.href = result.url;
|
||||||
|
} catch {
|
||||||
|
setPortalLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancelSubscription() {
|
||||||
|
const sub = subscription.subscription();
|
||||||
|
if (!sub || !sub.stripeId) return;
|
||||||
|
|
||||||
|
setCancelLoading(true);
|
||||||
|
try {
|
||||||
|
await api.billing.cancelSubscription.mutate({
|
||||||
|
subscriptionId: sub.stripeId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Error handled by trpc
|
||||||
|
} finally {
|
||||||
|
setCancelLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "active": return "bg-green-100 text-green-800";
|
||||||
|
case "trialing": return "bg-blue-100 text-blue-800";
|
||||||
|
case "past_due": return "bg-yellow-100 text-yellow-800";
|
||||||
|
case "canceled": return "bg-red-100 text-red-800";
|
||||||
|
case "unpaid": return "bg-red-100 text-red-800";
|
||||||
|
default: return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
|
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
|
||||||
<Title>Settings — Kordant</Title>
|
<Title>Settings — Kordant</Title>
|
||||||
@@ -50,12 +92,89 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card class="p-4">
|
<Card class="p-4 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-[var(--color-text-primary)] mb-4">Subscription</h2>
|
<h2 class="text-sm font-semibold text-[var(--color-text-primary)] mb-4">Subscription</h2>
|
||||||
<p class="text-sm text-[var(--color-text-secondary)] mb-1">Current Plan</p>
|
|
||||||
<p class="text-lg font-semibold text-[var(--color-text-primary)]">
|
<Show
|
||||||
{(subscription.tier().charAt(0).toUpperCase() + subscription.tier().slice(1)) || "Free"}
|
when={subscription.subscription()}
|
||||||
|
fallback={
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||||
|
You're on the free plan. Upgrade to unlock all features.
|
||||||
</p>
|
</p>
|
||||||
|
<A href="/pricing">
|
||||||
|
<Button variant="primary">View Plans</Button>
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(sub) => (
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{sub().tier?.charAt(0).toUpperCase() + sub().tier?.slice(1) || "Free"}
|
||||||
|
{sub().isTrialing ? " (Trial)" : ""}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-[var(--color-text-secondary)]">
|
||||||
|
<span class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${getStatusBadgeClass(sub().status ?? "active")}`}>
|
||||||
|
{sub().status}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when={sub().cancelAtPeriodEnd}>
|
||||||
|
<Badge variant="info">Cancels at period end</Badge>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={sub().currentPeriodEnd}>
|
||||||
|
{(end) => (
|
||||||
|
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||||
|
{sub().isTrialing && sub().trialEnd
|
||||||
|
? `Trial ends ${new Date(sub().trialEnd as any).toLocaleDateString()}`
|
||||||
|
: `Next billing date: ${new Date(end() as any).toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={sub().defaultPaymentMethodLast4}>
|
||||||
|
{(last4) => (
|
||||||
|
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||||
|
Payment method: •••• {last4()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={openBillingPortal}
|
||||||
|
loading={portalLoading()}
|
||||||
|
>
|
||||||
|
Manage Billing
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Show when={sub().status === "active" || sub().status === "trialing"}>
|
||||||
|
<Show when={!sub().cancelAtPeriodEnd}>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleCancelSubscription}
|
||||||
|
loading={cancelLoading()}
|
||||||
|
>
|
||||||
|
Cancel Subscription
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={sub().cancelAtPeriodEnd}>
|
||||||
|
<p class="text-xs text-[var(--color-text-secondary)] mt-3">
|
||||||
|
Your subscription will remain active until the end of your billing period.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -41,11 +41,36 @@ vi.mock("drizzle-orm", () => ({
|
|||||||
lt: vi.fn((col: any, val: any) => ({ column: col, value: val })),
|
lt: vi.fn((col: any, val: any) => ({ column: col, value: val })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Webhook deduplication", () => {
|
describe("Webhook handler", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should export POST handler", async () => {
|
||||||
|
const { POST } = await import("./webhook");
|
||||||
|
expect(POST).toBeDefined();
|
||||||
|
expect(typeof POST).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for missing signature", async () => {
|
||||||
|
const { POST } = await import("./webhook");
|
||||||
|
|
||||||
|
const mockHeaders = {
|
||||||
|
get: vi.fn().mockReturnValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
request: {
|
||||||
|
text: async () => "{}",
|
||||||
|
headers: mockHeaders,
|
||||||
|
},
|
||||||
|
url: "http://localhost/api/stripe/webhook",
|
||||||
|
} as unknown as Parameters<typeof POST>[0];
|
||||||
|
|
||||||
|
const response = await POST(mockRequest);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("should construct event from signed payload", async () => {
|
it("should construct event from signed payload", async () => {
|
||||||
const { stripe } = await import("~/server/stripe");
|
const { stripe } = await import("~/server/stripe");
|
||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
@@ -55,24 +80,9 @@ describe("Webhook deduplication", () => {
|
|||||||
};
|
};
|
||||||
vi.mocked(stripe.webhooks.constructEvent).mockReturnValue(mockEvent as any);
|
vi.mocked(stripe.webhooks.constructEvent).mockReturnValue(mockEvent as any);
|
||||||
|
|
||||||
const mockEvent2 = {
|
|
||||||
id: "evt_test123",
|
|
||||||
type: "checkout.session.completed",
|
|
||||||
data: { object: {} },
|
|
||||||
};
|
|
||||||
vi.mocked(stripe.webhooks.constructEvent).mockReturnValue(
|
|
||||||
mockEvent2 as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(stripe.webhooks.constructEvent).toBeDefined();
|
expect(stripe.webhooks.constructEvent).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 400 for missing signature", async () => {
|
|
||||||
// This tests the webhook handler behavior
|
|
||||||
const { POST } = await import("./webhook");
|
|
||||||
expect(POST).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should check for duplicate event ID before processing", async () => {
|
it("should check for duplicate event ID before processing", async () => {
|
||||||
const { db } = await import("~/server/db");
|
const { db } = await import("~/server/db");
|
||||||
const { stripeWebhookEvents } = await import(
|
const { stripeWebhookEvents } = await import(
|
||||||
@@ -98,4 +108,79 @@ describe("Webhook deduplication", () => {
|
|||||||
expect(lt).toBeDefined();
|
expect(lt).toBeDefined();
|
||||||
expect(db.delete).toBeDefined();
|
expect(db.delete).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should export cleanupWebhookEvents function", async () => {
|
||||||
|
const { cleanupWebhookEvents } = await import("./webhook");
|
||||||
|
expect(cleanupWebhookEvents).toBeDefined();
|
||||||
|
expect(typeof cleanupWebhookEvents).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Webhook deduplication", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip duplicate events", async () => {
|
||||||
|
const { db } = await import("~/server/db");
|
||||||
|
|
||||||
|
// Simulate existing event
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ id: "evt_dup" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dedup logic checks for existing events before processing
|
||||||
|
// This is verified by the fact that the select is called
|
||||||
|
expect(db.select).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Webhook idempotency", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use onConflictDoNothing for event recording", async () => {
|
||||||
|
const { db } = await import("~/server/db");
|
||||||
|
|
||||||
|
// Verify insert chain supports onConflictDoNothing
|
||||||
|
expect(db.insert).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle all critical Stripe event types", async () => {
|
||||||
|
const { handleWebhookEvent } = await import(
|
||||||
|
"~/server/services/billing.service"
|
||||||
|
);
|
||||||
|
|
||||||
|
const criticalEvents = [
|
||||||
|
"checkout.session.completed",
|
||||||
|
"invoice.payment_succeeded",
|
||||||
|
"invoice.paid",
|
||||||
|
"invoice.payment_failed",
|
||||||
|
"customer.subscription.updated",
|
||||||
|
"customer.subscription.deleted",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify handleWebhookEvent is a function
|
||||||
|
expect(typeof handleWebhookEvent).toBe("function");
|
||||||
|
|
||||||
|
for (const eventType of criticalEvents) {
|
||||||
|
// Each event type should be handled without synchronously throwing
|
||||||
|
// The function may return undefined or a resolved promise
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await handleWebhookEvent({
|
||||||
|
type: eventType,
|
||||||
|
data: { object: {} },
|
||||||
|
} as never);
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
expect(threw).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { schedulerRouter } from "./routers/scheduler";
|
|||||||
import { extensionRouter } from "./routers/extension";
|
import { extensionRouter } from "./routers/extension";
|
||||||
import { blogRouter } from "./routers/blog";
|
import { blogRouter } from "./routers/blog";
|
||||||
import { adminRouter } from "./routers/admin";
|
import { adminRouter } from "./routers/admin";
|
||||||
|
import { familyRouter } from "./routers/family";
|
||||||
import { createTRPCRouter } from "./utils";
|
import { createTRPCRouter } from "./utils";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
extension: extensionRouter,
|
extension: extensionRouter,
|
||||||
blog: blogRouter,
|
blog: blogRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
|
family: familyRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
CancelSubscriptionSchema,
|
CancelSubscriptionSchema,
|
||||||
ReactivateSubscriptionSchema,
|
ReactivateSubscriptionSchema,
|
||||||
ListInvoicesSchema,
|
ListInvoicesSchema,
|
||||||
|
CreateTrialSubscriptionSchema,
|
||||||
|
ChangeTierSchema,
|
||||||
} from "../schemas/billing";
|
} from "../schemas/billing";
|
||||||
|
|
||||||
vi.mock("~/server/services/billing.service", () => ({
|
vi.mock("~/server/services/billing.service", () => ({
|
||||||
@@ -16,6 +18,14 @@ vi.mock("~/server/services/billing.service", () => ({
|
|||||||
cancelSubscription: vi.fn(),
|
cancelSubscription: vi.fn(),
|
||||||
reactivateSubscription: vi.fn(),
|
reactivateSubscription: vi.fn(),
|
||||||
listInvoices: vi.fn(),
|
listInvoices: vi.fn(),
|
||||||
|
mapStripeProductToTier: vi.fn((p: string) => {
|
||||||
|
if (p.includes("basic")) return "basic";
|
||||||
|
if (p.includes("plus")) return "plus";
|
||||||
|
if (p.includes("premium")) return "premium";
|
||||||
|
return "basic";
|
||||||
|
}),
|
||||||
|
createTrialSubscription: vi.fn(),
|
||||||
|
changeSubscriptionTier: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mockFindFirst } = vi.hoisted(() => ({
|
const { mockFindFirst } = vi.hoisted(() => ({
|
||||||
@@ -32,12 +42,21 @@ vi.mock("~/server/db", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/lib/tier", () => ({
|
||||||
|
getEffectiveTier: vi.fn((tier: string) => tier),
|
||||||
|
getActiveTrials: vi.fn().mockResolvedValue([]),
|
||||||
|
createFeatureTrial: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createCheckoutSession,
|
createCheckoutSession,
|
||||||
createPortalSession,
|
createPortalSession,
|
||||||
cancelSubscription,
|
cancelSubscription,
|
||||||
reactivateSubscription,
|
reactivateSubscription,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
|
createTrialSubscription,
|
||||||
|
changeSubscriptionTier,
|
||||||
|
mapStripeProductToTier,
|
||||||
} from "~/server/services/billing.service";
|
} from "~/server/services/billing.service";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
@@ -46,6 +65,9 @@ const mockCreatePortalSession = vi.mocked(createPortalSession);
|
|||||||
const mockCancelSubscription = vi.mocked(cancelSubscription);
|
const mockCancelSubscription = vi.mocked(cancelSubscription);
|
||||||
const mockReactivateSubscription = vi.mocked(reactivateSubscription);
|
const mockReactivateSubscription = vi.mocked(reactivateSubscription);
|
||||||
const mockListInvoices = vi.mocked(listInvoices);
|
const mockListInvoices = vi.mocked(listInvoices);
|
||||||
|
const mockCreateTrialSubscription = vi.mocked(createTrialSubscription);
|
||||||
|
const mockChangeSubscriptionTier = vi.mocked(changeSubscriptionTier);
|
||||||
|
const mockMapStripeProductToTier = vi.mocked(mapStripeProductToTier);
|
||||||
const mockDb = vi.mocked(db);
|
const mockDb = vi.mocked(db);
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@@ -85,7 +107,40 @@ function createCaller(user: User | null) {
|
|||||||
.input(wrap(CreateCheckoutSessionSchema))
|
.input(wrap(CreateCheckoutSessionSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const i = input as { priceId: string; returnUrl: string };
|
const i = input as { priceId: string; returnUrl: string };
|
||||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.returnUrl);
|
const existing = await mockFindFirst();
|
||||||
|
const currentTier = existing?.tier;
|
||||||
|
const newTier = mockMapStripeProductToTier(i.priceId);
|
||||||
|
const tierOrder = { basic: 0, plus: 1, premium: 2 } as const;
|
||||||
|
const isUpgrade = currentTier && tierOrder[newTier as keyof typeof tierOrder] > tierOrder[currentTier as keyof typeof tierOrder];
|
||||||
|
const isDowngrade = currentTier && tierOrder[newTier as keyof typeof tierOrder] < tierOrder[currentTier as keyof typeof tierOrder];
|
||||||
|
|
||||||
|
if (existing && existing.stripeId && (isUpgrade || isDowngrade)) {
|
||||||
|
return mockChangeSubscriptionTier(existing.stripeId, i.priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.returnUrl, { isUpgrade, isDowngrade });
|
||||||
|
}),
|
||||||
|
createTrialSubscription: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(CreateTrialSubscriptionSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await mockFindFirst();
|
||||||
|
if (existing && (existing.status === "active" || existing.status === "trialing")) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: "Already has active subscription" });
|
||||||
|
}
|
||||||
|
return mockCreateTrialSubscription(ctx.user.id, ctx.user.email, (input as { returnUrl: string }).returnUrl);
|
||||||
|
}),
|
||||||
|
changeTier: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(ChangeTierSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const sub = await mockFindFirst();
|
||||||
|
if (!sub || !sub.stripeId) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription" });
|
||||||
|
}
|
||||||
|
const tier = (input as { tier: string }).tier;
|
||||||
|
const priceMap: Record<string, string> = {
|
||||||
|
basic: "price_basic", plus: "price_plus", premium: "price_premium",
|
||||||
|
};
|
||||||
|
return mockChangeSubscriptionTier(sub.stripeId, priceMap[tier]);
|
||||||
}),
|
}),
|
||||||
createPortalSession: t.procedure.use(isAuthed)
|
createPortalSession: t.procedure.use(isAuthed)
|
||||||
.input(wrap(CreatePortalSessionSchema))
|
.input(wrap(CreatePortalSessionSchema))
|
||||||
@@ -160,6 +215,7 @@ describe("billing.getSubscription", () => {
|
|||||||
|
|
||||||
describe("billing.createCheckoutSession", () => {
|
describe("billing.createCheckoutSession", () => {
|
||||||
it("creates checkout session and returns clientSecret", async () => {
|
it("creates checkout session and returns clientSecret", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
mockCreateCheckoutSession.mockResolvedValue({
|
mockCreateCheckoutSession.mockResolvedValue({
|
||||||
clientSecret: "cs_123_secret",
|
clientSecret: "cs_123_secret",
|
||||||
sessionId: "session_123",
|
sessionId: "session_123",
|
||||||
@@ -169,11 +225,75 @@ describe("billing.createCheckoutSession", () => {
|
|||||||
const result = await api.createCheckoutSession({
|
const result = await api.createCheckoutSession({
|
||||||
priceId: "price_basic",
|
priceId: "price_basic",
|
||||||
returnUrl: "https://example.com/return",
|
returnUrl: "https://example.com/return",
|
||||||
});
|
}) as { clientSecret: string; sessionId: string };
|
||||||
|
|
||||||
expect(result.clientSecret).toBe("cs_123_secret");
|
expect(result.clientSecret).toBe("cs_123_secret");
|
||||||
expect(result.sessionId).toBe("session_123");
|
expect(result.sessionId).toBe("session_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("triggers tier change for upgrade", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({
|
||||||
|
id: "sub-1", stripeId: "sub_stripe_1", tier: "basic", status: "active",
|
||||||
|
});
|
||||||
|
mockChangeSubscriptionTier.mockResolvedValue({ subscription: { id: "sub_stripe_1" } as any });
|
||||||
|
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await api.createCheckoutSession({
|
||||||
|
priceId: "price_plus",
|
||||||
|
returnUrl: "https://example.com/return",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockChangeSubscriptionTier).toHaveBeenCalledWith("sub_stripe_1", "price_plus");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("billing.createTrialSubscription", () => {
|
||||||
|
it("creates trial subscription for user without active sub", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
mockCreateTrialSubscription.mockResolvedValue({
|
||||||
|
sessionId: "session_trial",
|
||||||
|
url: "https://checkout.stripe.com/trial",
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.createTrialSubscription({
|
||||||
|
returnUrl: "https://example.com/return",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe("session_trial");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects user with active subscription", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({
|
||||||
|
id: "sub-1", stripeId: "sub_stripe_1", tier: "basic", status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await expect(api.createTrialSubscription({
|
||||||
|
returnUrl: "https://example.com/return",
|
||||||
|
})).rejects.toThrow(TRPCError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("billing.changeTier", () => {
|
||||||
|
it("changes tier with proration", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({
|
||||||
|
id: "sub-1", stripeId: "sub_stripe_1", tier: "basic", status: "active",
|
||||||
|
});
|
||||||
|
mockChangeSubscriptionTier.mockResolvedValue({ subscription: { id: "sub_stripe_1" } as any });
|
||||||
|
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.changeTier({ tier: "plus" });
|
||||||
|
|
||||||
|
expect(mockChangeSubscriptionTier).toHaveBeenCalledWith("sub_stripe_1", "price_plus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when no subscription exists", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await expect(api.changeTier({ tier: "plus" })).rejects.toThrow(TRPCError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("billing.createPortalSession", () => {
|
describe("billing.createPortalSession", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { wrap } from "@typeschema/valibot";
|
import { wrap } from "@typeschema/valibot";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
import { createTRPCRouter, protectedProcedure, rateLimitedProcedure } from "../utils";
|
||||||
import {
|
import {
|
||||||
CreateCheckoutSessionSchema,
|
CreateCheckoutSessionSchema,
|
||||||
CreatePortalSessionSchema,
|
CreatePortalSessionSchema,
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
ListInvoicesSchema,
|
ListInvoicesSchema,
|
||||||
RequestFeatureTrialSchema,
|
RequestFeatureTrialSchema,
|
||||||
UpgradeFromTrialSchema,
|
UpgradeFromTrialSchema,
|
||||||
|
CreateTrialSubscriptionSchema,
|
||||||
|
ChangeTierSchema,
|
||||||
|
CreateFamilyCheckoutSessionSchema,
|
||||||
} from "../schemas/billing";
|
} from "../schemas/billing";
|
||||||
import {
|
import {
|
||||||
getOrCreateCustomer,
|
getOrCreateCustomer,
|
||||||
@@ -19,15 +22,22 @@ import {
|
|||||||
reactivateSubscription,
|
reactivateSubscription,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
mapStripeProductToTier,
|
mapStripeProductToTier,
|
||||||
|
createTrialSubscription,
|
||||||
|
changeSubscriptionTier,
|
||||||
} from "~/server/services/billing.service";
|
} from "~/server/services/billing.service";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { subscriptions } from "~/server/db/schema/subscription";
|
import { subscriptions, familyGroups } from "~/server/db/schema/subscription";
|
||||||
import { stripe } from "~/server/stripe";
|
import { stripe } from "~/server/stripe";
|
||||||
import {
|
import {
|
||||||
getEffectiveTier,
|
getEffectiveTier,
|
||||||
getActiveTrials,
|
getActiveTrials,
|
||||||
createFeatureTrial,
|
createFeatureTrial,
|
||||||
|
TIER_ORDER,
|
||||||
} from "~/server/lib/tier";
|
} from "~/server/lib/tier";
|
||||||
|
import {
|
||||||
|
createFamilyGroup,
|
||||||
|
getFamilyGroup,
|
||||||
|
} from "~/server/services/family.service";
|
||||||
|
|
||||||
export const billingRouter = createTRPCRouter({
|
export const billingRouter = createTRPCRouter({
|
||||||
getSubscription: protectedProcedure.query(async ({ ctx }) => {
|
getSubscription: protectedProcedure.query(async ({ ctx }) => {
|
||||||
@@ -38,7 +48,10 @@ export const billingRouter = createTRPCRouter({
|
|||||||
const trials = await getActiveTrials(ctx.user.id);
|
const trials = await getActiveTrials(ctx.user.id);
|
||||||
return {
|
return {
|
||||||
...sub,
|
...sub,
|
||||||
effectiveTier: getEffectiveTier(sub.tier as "basic" | "plus" | "premium", sub.status as "active" | "trialing"),
|
effectiveTier: getEffectiveTier(
|
||||||
|
sub.tier as "basic" | "plus" | "premium",
|
||||||
|
sub.status as "active" | "trialing",
|
||||||
|
),
|
||||||
isTrialing: sub.status === "trialing",
|
isTrialing: sub.status === "trialing",
|
||||||
trials,
|
trials,
|
||||||
};
|
};
|
||||||
@@ -80,6 +93,8 @@ export const billingRouter = createTRPCRouter({
|
|||||||
basic: process.env.STRIPE_PRICE_BASIC,
|
basic: process.env.STRIPE_PRICE_BASIC,
|
||||||
plus: process.env.STRIPE_PRICE_PLUS,
|
plus: process.env.STRIPE_PRICE_PLUS,
|
||||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||||
|
family_guard: process.env.STRIPE_PRICE_FAMILY_GUARD,
|
||||||
|
family_fortress: process.env.STRIPE_PRICE_FAMILY_FORTRESS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const priceId = priceMap[input.plan];
|
const priceId = priceMap[input.plan];
|
||||||
@@ -98,9 +113,41 @@ export const billingRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createCheckoutSession: protectedProcedure
|
/**
|
||||||
|
* Create a 14-day trial subscription.
|
||||||
|
* No payment method required — Stripe Checkout collects it on conversion.
|
||||||
|
*/
|
||||||
|
createTrialSubscription: rateLimitedProcedure
|
||||||
|
.input(wrap(CreateTrialSubscriptionSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = ctx.user!;
|
||||||
|
// Check if user already has an active or trialing subscription
|
||||||
|
const existing = await db.query.subscriptions.findFirst({
|
||||||
|
where: eq(subscriptions.userId, user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing && (existing.status === "active" || existing.status === "trialing")) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "You already have an active subscription",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createTrialSubscription(
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
input.returnUrl,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a checkout session for a paid plan.
|
||||||
|
* Rate limited to prevent abuse.
|
||||||
|
*/
|
||||||
|
createCheckoutSession: rateLimitedProcedure
|
||||||
.input(wrap(CreateCheckoutSessionSchema))
|
.input(wrap(CreateCheckoutSessionSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = ctx.user!;
|
||||||
const allowedPrices = [
|
const allowedPrices = [
|
||||||
process.env.STRIPE_PRICE_BASIC,
|
process.env.STRIPE_PRICE_BASIC,
|
||||||
process.env.STRIPE_PRICE_PLUS,
|
process.env.STRIPE_PRICE_PLUS,
|
||||||
@@ -114,18 +161,120 @@ export const billingRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is an upgrade or downgrade
|
||||||
|
const existing = await db.query.subscriptions.findFirst({
|
||||||
|
where: eq(subscriptions.userId, user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTier = existing?.tier;
|
||||||
|
const newTier = mapStripeProductToTier(input.priceId);
|
||||||
|
const tierOrder: Record<string, number> = { basic: 0, plus: 1, premium: 2 };
|
||||||
|
|
||||||
|
const isUpgrade = Boolean(
|
||||||
|
currentTier &&
|
||||||
|
tierOrder[newTier] > tierOrder[currentTier],
|
||||||
|
);
|
||||||
|
const isDowngrade = Boolean(
|
||||||
|
currentTier &&
|
||||||
|
tierOrder[newTier] < tierOrder[currentTier],
|
||||||
|
);
|
||||||
|
|
||||||
|
// If user has an active subscription and this is an upgrade/downgrade,
|
||||||
|
// use the tier change flow with proration instead of new checkout
|
||||||
|
if (existing && existing.stripeId && (isUpgrade || isDowngrade)) {
|
||||||
|
return changeSubscriptionTier(existing.stripeId, input.priceId);
|
||||||
|
}
|
||||||
|
|
||||||
return createCheckoutSession(
|
return createCheckoutSession(
|
||||||
ctx.user.id,
|
user.id,
|
||||||
ctx.user.email,
|
user.email,
|
||||||
input.priceId,
|
input.priceId,
|
||||||
input.returnUrl,
|
input.returnUrl,
|
||||||
|
{ isUpgrade, isDowngrade },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a checkout session for a family plan.
|
||||||
|
* Creates a family group and starts the subscription checkout.
|
||||||
|
*/
|
||||||
|
createFamilyCheckoutSession: rateLimitedProcedure
|
||||||
|
.input(wrap(CreateFamilyCheckoutSessionSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = ctx.user!;
|
||||||
|
|
||||||
|
const priceMap: Record<string, string | undefined> = {
|
||||||
|
family_guard: process.env.STRIPE_PRICE_FAMILY_GUARD,
|
||||||
|
family_fortress: process.env.STRIPE_PRICE_FAMILY_FORTRESS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const priceId = priceMap[input.tier];
|
||||||
|
if (!priceId) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid family plan tier" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create family group first
|
||||||
|
const group = await createFamilyGroup(user.id, `${user.name ?? "Your"} Family`, input.tier);
|
||||||
|
|
||||||
|
// Create checkout session for the family plan
|
||||||
|
const session = await createCheckoutSession(
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
priceId,
|
||||||
|
input.returnUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link subscription to family group after successful checkout is handled by webhook
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
familyGroupId: group.id,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change subscription tier with proration.
|
||||||
|
*/
|
||||||
|
changeTier: protectedProcedure
|
||||||
|
.input(wrap(ChangeTierSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const sub = await db.query.subscriptions.findFirst({
|
||||||
|
where: eq(subscriptions.userId, ctx.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sub || !sub.stripeId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No active subscription found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub.status !== "active" && sub.status !== "trialing") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot change tier for subscription in " + sub.status + " status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceMap: Record<string, string | undefined> = {
|
||||||
|
basic: process.env.STRIPE_PRICE_BASIC,
|
||||||
|
plus: process.env.STRIPE_PRICE_PLUS,
|
||||||
|
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||||
|
family_guard: process.env.STRIPE_PRICE_FAMILY_GUARD,
|
||||||
|
family_fortress: process.env.STRIPE_PRICE_FAMILY_FORTRESS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const priceId = priceMap[input.tier];
|
||||||
|
if (!priceId) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid tier" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return changeSubscriptionTier(sub.stripeId, priceId);
|
||||||
|
}),
|
||||||
|
|
||||||
createPortalSession: protectedProcedure
|
createPortalSession: protectedProcedure
|
||||||
.input(wrap(CreatePortalSessionSchema))
|
.input(wrap(CreatePortalSessionSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = ctx.user;
|
const user = ctx.user!;
|
||||||
const stripeCustomerId = user.stripeCustomerId;
|
const stripeCustomerId = user.stripeCustomerId;
|
||||||
|
|
||||||
if (!stripeCustomerId) {
|
if (!stripeCustomerId) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
GroupFilterSchema,
|
GroupFilterSchema,
|
||||||
GroupDetailsSchema,
|
GroupDetailsSchema,
|
||||||
ResolveAlertSchema,
|
ResolveAlertSchema,
|
||||||
|
FamilyThreatScoreSchema,
|
||||||
} from "../schemas/correlation";
|
} from "../schemas/correlation";
|
||||||
|
|
||||||
vi.mock("~/server/services/correlation.service", () => ({
|
vi.mock("~/server/services/correlation.service", () => ({
|
||||||
@@ -16,6 +17,11 @@ vi.mock("~/server/services/correlation.service", () => ({
|
|||||||
getCorrelationGroupDetails: vi.fn(),
|
getCorrelationGroupDetails: vi.fn(),
|
||||||
resolveAlert: vi.fn(),
|
resolveAlert: vi.fn(),
|
||||||
getAlertStats: vi.fn(),
|
getAlertStats: vi.fn(),
|
||||||
|
getThreatScore: vi.fn(),
|
||||||
|
getThreatScoreTrend: vi.fn(),
|
||||||
|
getRecommendations: vi.fn(),
|
||||||
|
getFamilyThreatScore: vi.fn(),
|
||||||
|
correlateAlerts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import * as correlationService from "~/server/services/correlation.service";
|
import * as correlationService from "~/server/services/correlation.service";
|
||||||
@@ -26,6 +32,11 @@ const mockGetCorrelationGroups = vi.mocked(correlationService.getCorrelationGrou
|
|||||||
const mockGetCorrelationGroupDetails = vi.mocked(correlationService.getCorrelationGroupDetails);
|
const mockGetCorrelationGroupDetails = vi.mocked(correlationService.getCorrelationGroupDetails);
|
||||||
const mockResolveAlert = vi.mocked(correlationService.resolveAlert);
|
const mockResolveAlert = vi.mocked(correlationService.resolveAlert);
|
||||||
const mockGetAlertStats = vi.mocked(correlationService.getAlertStats);
|
const mockGetAlertStats = vi.mocked(correlationService.getAlertStats);
|
||||||
|
const mockGetThreatScore = vi.mocked(correlationService.getThreatScore);
|
||||||
|
const mockGetThreatScoreTrend = vi.mocked(correlationService.getThreatScoreTrend);
|
||||||
|
const mockGetRecommendations = vi.mocked(correlationService.getRecommendations);
|
||||||
|
const mockGetFamilyThreatScore = vi.mocked(correlationService.getFamilyThreatScore);
|
||||||
|
const mockCorrelateAlerts = vi.mocked(correlationService.correlateAlerts);
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: string; email: string; name: string | null; image: string | null;
|
id: string; email: string; name: string | null; image: string | null;
|
||||||
@@ -71,6 +82,23 @@ function createCaller(user: User | null) {
|
|||||||
getStats: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
getStats: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||||
return mockGetAlertStats(ctx.user.id);
|
return mockGetAlertStats(ctx.user.id);
|
||||||
}),
|
}),
|
||||||
|
getThreatScore: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||||
|
return mockGetThreatScore(ctx.user.id);
|
||||||
|
}),
|
||||||
|
getThreatScoreTrend: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||||
|
return mockGetThreatScoreTrend(ctx.user.id);
|
||||||
|
}),
|
||||||
|
getRecommendations: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||||
|
return mockGetRecommendations(ctx.user.id);
|
||||||
|
}),
|
||||||
|
getFamilyThreatScore: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(FamilyThreatScoreSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return mockGetFamilyThreatScore(input.groupId);
|
||||||
|
}),
|
||||||
|
runCorrelation: t.procedure.use(isAuthed).mutation(async ({ ctx }) => {
|
||||||
|
return mockCorrelateAlerts(ctx.user.id);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const caller = t.createCallerFactory(router);
|
const caller = t.createCallerFactory(router);
|
||||||
@@ -205,7 +233,7 @@ describe("correlation.resolveAlert", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("correlation.getStats", () => {
|
describe("correlation.getStats", () => {
|
||||||
it("returns alert statistics", async () => {
|
it("returns alert statistics with correlation data", async () => {
|
||||||
const stats = {
|
const stats = {
|
||||||
totalAlerts: 10,
|
totalAlerts: 10,
|
||||||
bySeverity: { HIGH: 5, LOW: 5 },
|
bySeverity: { HIGH: 5, LOW: 5 },
|
||||||
@@ -215,11 +243,132 @@ describe("correlation.getStats", () => {
|
|||||||
falsePositiveCount: 0,
|
falsePositiveCount: 0,
|
||||||
threatScore: 45,
|
threatScore: 45,
|
||||||
threatBreakdown: [{ source: "DARKWATCH", score: 45 }],
|
threatBreakdown: [{ source: "DARKWATCH", score: 45 }],
|
||||||
|
correlationBonus: 30,
|
||||||
|
correlationCount: 1,
|
||||||
|
narratives: ["Your email was breached and you received spam — possible coordinated attack"],
|
||||||
|
recommendations: ["Enable two-factor authentication"],
|
||||||
};
|
};
|
||||||
mockGetAlertStats.mockResolvedValue(stats as never);
|
mockGetAlertStats.mockResolvedValue(stats as never);
|
||||||
const api = createCaller(makeUser());
|
const api = createCaller(makeUser());
|
||||||
const result = await api.getStats();
|
const result = await api.getStats();
|
||||||
expect(result.totalAlerts).toBe(10);
|
expect(result.totalAlerts).toBe(10);
|
||||||
expect(result.threatScore).toBe(45);
|
expect(result.threatScore).toBe(45);
|
||||||
|
expect(result.correlationBonus).toBe(30);
|
||||||
|
expect(result.narratives.length).toBe(1);
|
||||||
|
expect(result.recommendations.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getThreatScore", () => {
|
||||||
|
it("returns full threat score with correlation breakdown", async () => {
|
||||||
|
const score = {
|
||||||
|
score: 55,
|
||||||
|
baseScore: 25,
|
||||||
|
correlationBonus: 30,
|
||||||
|
alertCount: 5,
|
||||||
|
correlationCount: 1,
|
||||||
|
sourceBreakdown: { DARKWATCH: 15, SPAMSHIELD: 10 },
|
||||||
|
severityBreakdown: { HIGH: 20, WARNING: 5 },
|
||||||
|
ruleBreakdown: [{ rule: "RULE_1", bonus: 30, name: "Coordinated Attack: Breach + Spam" }],
|
||||||
|
narratives: ["Your email was breached..."],
|
||||||
|
recommendations: ["Enable 2FA"],
|
||||||
|
};
|
||||||
|
mockGetThreatScore.mockResolvedValue(score as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getThreatScore();
|
||||||
|
expect(result.score).toBe(55);
|
||||||
|
expect(result.baseScore).toBe(25);
|
||||||
|
expect(result.correlationBonus).toBe(30);
|
||||||
|
expect(result.ruleBreakdown.length).toBe(1);
|
||||||
|
expect(result.ruleBreakdown[0].rule).toBe("RULE_1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getThreatScoreTrend", () => {
|
||||||
|
it("returns trend data with data points", async () => {
|
||||||
|
const trend = {
|
||||||
|
dataPoints: [
|
||||||
|
{ date: "2024-01-01", score: 10 },
|
||||||
|
{ date: "2024-01-15", score: 25 },
|
||||||
|
{ date: "2024-02-01", score: 55 },
|
||||||
|
],
|
||||||
|
currentScore: 55,
|
||||||
|
previousScore: 25,
|
||||||
|
change: 30,
|
||||||
|
threatLevel: { level: "medium", color: "yellow", label: "Medium Risk" },
|
||||||
|
};
|
||||||
|
mockGetThreatScoreTrend.mockResolvedValue(trend as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getThreatScoreTrend();
|
||||||
|
expect(result.dataPoints.length).toBe(3);
|
||||||
|
expect(result.currentScore).toBe(55);
|
||||||
|
expect(result.change).toBe(30);
|
||||||
|
expect(result.threatLevel.level).toBe("medium");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getRecommendations", () => {
|
||||||
|
it("returns prioritized recommendations", async () => {
|
||||||
|
const recs = {
|
||||||
|
recommendations: [
|
||||||
|
{ priority: "critical", text: "Your threat score is critically high" },
|
||||||
|
{ priority: "high", text: "Change passwords on all critical accounts" },
|
||||||
|
{ priority: "medium", text: "Enable two-factor authentication" },
|
||||||
|
],
|
||||||
|
narratives: ["Coordinated attack detected"],
|
||||||
|
score: 75,
|
||||||
|
threatLevel: { level: "high", color: "orange", label: "High Risk" },
|
||||||
|
};
|
||||||
|
mockGetRecommendations.mockResolvedValue(recs as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getRecommendations();
|
||||||
|
expect(result.recommendations.length).toBe(3);
|
||||||
|
expect(result.recommendations[0].priority).toBe("critical");
|
||||||
|
expect(result.threatLevel.level).toBe("high");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getFamilyThreatScore", () => {
|
||||||
|
it("returns family-aggregated score", async () => {
|
||||||
|
const familyScore = {
|
||||||
|
familyScore: 65,
|
||||||
|
memberScores: [
|
||||||
|
{ userId: "u1", score: 80 },
|
||||||
|
{ userId: "u2", score: 30 },
|
||||||
|
{ userId: "u3", score: 50 },
|
||||||
|
],
|
||||||
|
recommendations: [
|
||||||
|
{ priority: "high", text: "Change passwords" },
|
||||||
|
],
|
||||||
|
narratives: ["Coordinated attack on family member"],
|
||||||
|
};
|
||||||
|
mockGetFamilyThreatScore.mockResolvedValue(familyScore as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getFamilyThreatScore({ groupId: "family-1" });
|
||||||
|
expect(result.familyScore).toBe(65);
|
||||||
|
expect(result.memberScores.length).toBe(3);
|
||||||
|
expect(result.recommendations.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.runCorrelation", () => {
|
||||||
|
it("triggers correlation pipeline", async () => {
|
||||||
|
const result = {
|
||||||
|
score: 55,
|
||||||
|
baseScore: 25,
|
||||||
|
correlationBonus: 30,
|
||||||
|
alertCount: 5,
|
||||||
|
correlationCount: 1,
|
||||||
|
sourceBreakdown: {},
|
||||||
|
severityBreakdown: {},
|
||||||
|
ruleBreakdown: [{ rule: "RULE_1", bonus: 30, name: "Coordinated Attack" }],
|
||||||
|
narratives: ["Narrative"],
|
||||||
|
recommendations: ["Recommendation"],
|
||||||
|
};
|
||||||
|
mockCorrelateAlerts.mockResolvedValue(result as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const data = await api.runCorrelation();
|
||||||
|
expect(data.score).toBe(55);
|
||||||
|
expect(mockCorrelateAlerts).toHaveBeenCalledWith("user-1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,41 +6,75 @@ import {
|
|||||||
GroupFilterSchema,
|
GroupFilterSchema,
|
||||||
GroupDetailsSchema,
|
GroupDetailsSchema,
|
||||||
ResolveAlertSchema,
|
ResolveAlertSchema,
|
||||||
|
FamilyThreatScoreSchema,
|
||||||
} from "../schemas/correlation";
|
} from "../schemas/correlation";
|
||||||
import * as correlationService from "~/server/services/correlation.service";
|
import * as correlationService from "~/server/services/correlation.service";
|
||||||
|
|
||||||
export const correlationRouter = createTRPCRouter({
|
export const correlationRouter = createTRPCRouter({
|
||||||
|
// Alert timeline (paginated)
|
||||||
getAlerts: protectedProcedure
|
getAlerts: protectedProcedure
|
||||||
.input(wrap(AlertFilterSchema))
|
.input(wrap(AlertFilterSchema))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return correlationService.getAlertTimeline(ctx.user.id, input);
|
return correlationService.getAlertTimeline(ctx.user.id, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Individual alert details with correlation group info
|
||||||
getAlertDetails: protectedProcedure
|
getAlertDetails: protectedProcedure
|
||||||
.input(wrap(AlertDetailsSchema))
|
.input(wrap(AlertDetailsSchema))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return correlationService.getAlertDetails(ctx.user.id, input.alertId);
|
return correlationService.getAlertDetails(ctx.user.id, input.alertId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Correlation groups (paginated)
|
||||||
getGroups: protectedProcedure
|
getGroups: protectedProcedure
|
||||||
.input(wrap(GroupFilterSchema))
|
.input(wrap(GroupFilterSchema))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return correlationService.getCorrelationGroups(ctx.user.id, input);
|
return correlationService.getCorrelationGroups(ctx.user.id, input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Correlation group details with all linked alerts
|
||||||
getGroupDetails: protectedProcedure
|
getGroupDetails: protectedProcedure
|
||||||
.input(wrap(GroupDetailsSchema))
|
.input(wrap(GroupDetailsSchema))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return correlationService.getCorrelationGroupDetails(ctx.user.id, input.groupId);
|
return correlationService.getCorrelationGroupDetails(ctx.user.id, input.groupId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Resolve an alert (marks correlation group as resolved/false positive)
|
||||||
resolveAlert: protectedProcedure
|
resolveAlert: protectedProcedure
|
||||||
.input(wrap(ResolveAlertSchema))
|
.input(wrap(ResolveAlertSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return correlationService.resolveAlert(ctx.user.id, input.alertId, input.resolution);
|
return correlationService.resolveAlert(ctx.user.id, input.alertId, input.resolution);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Alert stats with threat score, breakdown, narratives, and recommendations
|
||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return correlationService.getAlertStats(ctx.user.id);
|
return correlationService.getAlertStats(ctx.user.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Full threat score with correlation rules, narratives, and recommendations
|
||||||
|
getThreatScore: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return correlationService.getThreatScore(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Threat score trend data for 90-day graph
|
||||||
|
getThreatScoreTrend: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return correlationService.getThreatScoreTrend(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Proactive recommendations based on current threat state
|
||||||
|
getRecommendations: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return correlationService.getRecommendations(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Family-aggregated threat score
|
||||||
|
getFamilyThreatScore: protectedProcedure
|
||||||
|
.input(wrap(FamilyThreatScoreSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return correlationService.getFamilyThreatScore(input.groupId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Trigger correlation pipeline manually
|
||||||
|
runCorrelation: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
return correlationService.correlateAlerts(ctx.user.id);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe("darkwatch.getExposureDetails", () => {
|
|||||||
|
|
||||||
describe("darkwatch.runScan", () => {
|
describe("darkwatch.runScan", () => {
|
||||||
it("triggers a scan", async () => {
|
it("triggers a scan", async () => {
|
||||||
mockRunScan.mockResolvedValue({ scanId: "s1" });
|
mockRunScan.mockResolvedValue({ scanId: "s1", queued: false });
|
||||||
const api = createCaller(makeUser());
|
const api = createCaller(makeUser());
|
||||||
const result = await api.runScan({});
|
const result = await api.runScan({});
|
||||||
expect(result.scanId).toBe("s1");
|
expect(result.scanId).toBe("s1");
|
||||||
@@ -184,7 +184,7 @@ describe("darkwatch.runScan", () => {
|
|||||||
|
|
||||||
describe("darkwatch.getScanStatus", () => {
|
describe("darkwatch.getScanStatus", () => {
|
||||||
it("returns scan status", async () => {
|
it("returns scan status", async () => {
|
||||||
mockGetScanStatus.mockResolvedValue({ status: "idle", startedAt: null, completedAt: null, progress: 0, error: null });
|
mockGetScanStatus.mockResolvedValue({ status: "idle", scanId: null, startedAt: null, completedAt: null, progress: 0, currentSource: null, error: null });
|
||||||
const api = createCaller(makeUser());
|
const api = createCaller(makeUser());
|
||||||
const result = await api.getScanStatus();
|
const result = await api.getScanStatus();
|
||||||
expect(result.status).toBe("idle");
|
expect(result.status).toBe("idle");
|
||||||
|
|||||||
272
web/src/server/api/routers/family.ts
Normal file
272
web/src/server/api/routers/family.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { wrap } from "@typeschema/valibot";
|
||||||
|
import { createTRPCRouter, protectedProcedure, rateLimitedProcedure } from "../utils";
|
||||||
|
import {
|
||||||
|
CreateFamilyGroupSchema,
|
||||||
|
InviteFamilyMemberSchema,
|
||||||
|
AcceptInvitationSchema,
|
||||||
|
ResendInvitationSchema,
|
||||||
|
CancelInvitationSchema,
|
||||||
|
RemoveFamilyMemberSchema,
|
||||||
|
LeaveFamilyGroupSchema,
|
||||||
|
UpdateMemberRoleSchema,
|
||||||
|
TransferOwnershipSchema,
|
||||||
|
ConfigureServicesSchema,
|
||||||
|
UpdateAlertPreferencesSchema,
|
||||||
|
UpdateFamilyPlanTierSchema,
|
||||||
|
MemberDetailSchema,
|
||||||
|
} from "../schemas/family";
|
||||||
|
import {
|
||||||
|
getFamilyGroup,
|
||||||
|
getFamilyGroupById,
|
||||||
|
createFamilyGroup,
|
||||||
|
updateFamilyPlanTier,
|
||||||
|
inviteMember,
|
||||||
|
acceptInvitation,
|
||||||
|
resendInvitation,
|
||||||
|
cancelInvitation,
|
||||||
|
listPendingInvitations,
|
||||||
|
removeMember,
|
||||||
|
leaveFamilyGroup,
|
||||||
|
updateMemberRole,
|
||||||
|
transferOwnership,
|
||||||
|
getFamilyDashboard,
|
||||||
|
getMemberDetail,
|
||||||
|
configureMemberServices,
|
||||||
|
getMemberServices,
|
||||||
|
updateMemberAlertPreferences,
|
||||||
|
getAlertRouting,
|
||||||
|
} from "~/server/services/family.service";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { familyGroups } from "~/server/db/schema/subscription";
|
||||||
|
|
||||||
|
export const familyRouter = createTRPCRouter({
|
||||||
|
/**
|
||||||
|
* Get the current user's family group with members.
|
||||||
|
*/
|
||||||
|
getGroup: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return getFamilyGroup(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new family group.
|
||||||
|
*/
|
||||||
|
createGroup: protectedProcedure
|
||||||
|
.input(wrap(CreateFamilyGroupSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Check if user already has a family group
|
||||||
|
try {
|
||||||
|
await getFamilyGroup(ctx.user.id);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "You already belong to a family group",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
||||||
|
// No existing group — good to create
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createFamilyGroup(ctx.user.id, input.name, input.planTier);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the family plan tier.
|
||||||
|
*/
|
||||||
|
updatePlanTier: protectedProcedure
|
||||||
|
.input(wrap(UpdateFamilyPlanTierSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
return updateFamilyPlanTier(group.id, input.planTier, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the family dashboard with all members' threat scores and alert counts.
|
||||||
|
* No sensitive breach details are exposed for other members.
|
||||||
|
*/
|
||||||
|
getDashboard: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return getFamilyDashboard(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed view of a specific member.
|
||||||
|
* Sensitive data (SSN, breach details) only visible to the member themselves or the owner.
|
||||||
|
*/
|
||||||
|
getMemberDetail: protectedProcedure
|
||||||
|
.input(wrap(MemberDetailSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
return getMemberDetail(group.id, input.userId, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite a family member by email.
|
||||||
|
* Sends an email with a signed invitation token.
|
||||||
|
* Enforces plan tier member limits.
|
||||||
|
*/
|
||||||
|
inviteMember: protectedProcedure
|
||||||
|
.input(wrap(InviteFamilyMemberSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
|
||||||
|
const callerMember = group.members.find(
|
||||||
|
(m) => m.userId === ctx.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only owner or admin can invite members",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return inviteMember(
|
||||||
|
group.id,
|
||||||
|
input.email,
|
||||||
|
ctx.user.id,
|
||||||
|
input.role,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a family invitation using the signed token.
|
||||||
|
* Called when a user clicks the invitation link.
|
||||||
|
*/
|
||||||
|
acceptInvitation: protectedProcedure
|
||||||
|
.input(wrap(AcceptInvitationSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return acceptInvitation(input.token, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend a pending invitation (sends reminder email).
|
||||||
|
*/
|
||||||
|
resendInvitation: protectedProcedure
|
||||||
|
.input(wrap(ResendInvitationSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return resendInvitation(input.invitationId, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a pending invitation.
|
||||||
|
*/
|
||||||
|
cancelInvitation: protectedProcedure
|
||||||
|
.input(wrap(CancelInvitationSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return cancelInvitation(input.invitationId, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all pending invitations for the family group.
|
||||||
|
*/
|
||||||
|
listInvitations: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
return listPendingInvitations(group.id, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from the family group.
|
||||||
|
* Creates a prorated credit via Stripe.
|
||||||
|
*/
|
||||||
|
removeMember: protectedProcedure
|
||||||
|
.input(wrap(RemoveFamilyMemberSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
await removeMember(group.id, input.userId, ctx.user.id);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave the family group voluntarily.
|
||||||
|
* Cannot leave if you are the owner.
|
||||||
|
*/
|
||||||
|
leaveGroup: protectedProcedure
|
||||||
|
.input(wrap(LeaveFamilyGroupSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await leaveFamilyGroup(input.groupId, ctx.user.id);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's role.
|
||||||
|
*/
|
||||||
|
updateMemberRole: protectedProcedure
|
||||||
|
.input(wrap(UpdateMemberRoleSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
const updated = await updateMemberRole(
|
||||||
|
group.id,
|
||||||
|
input.userId,
|
||||||
|
input.role,
|
||||||
|
ctx.user.id,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer ownership to another member.
|
||||||
|
*/
|
||||||
|
transferOwnership: protectedProcedure
|
||||||
|
.input(wrap(TransferOwnershipSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
await transferOwnership(group.id, input.newOwnerId, ctx.user.id);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure which services a member has access to.
|
||||||
|
* Only owner/admin can configure.
|
||||||
|
*/
|
||||||
|
configureServices: protectedProcedure
|
||||||
|
.input(wrap(ConfigureServicesSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
return configureMemberServices(
|
||||||
|
group.id,
|
||||||
|
input.userId,
|
||||||
|
input.services,
|
||||||
|
ctx.user.id,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a member's configured services.
|
||||||
|
*/
|
||||||
|
getMemberServices: protectedProcedure
|
||||||
|
.input(wrap(MemberDetailSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
return getMemberServices(group.id, input.userId, ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update own alert notification preferences.
|
||||||
|
*/
|
||||||
|
updateAlertPreferences: protectedProcedure
|
||||||
|
.input(wrap(UpdateAlertPreferencesSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return updateMemberAlertPreferences(
|
||||||
|
input.groupId,
|
||||||
|
ctx.user.id,
|
||||||
|
input.preferences,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get alert routing for a given alert type.
|
||||||
|
* Used internally by the notification system.
|
||||||
|
*/
|
||||||
|
getAlertRouting: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
return {
|
||||||
|
critical: await getAlertRouting(group.id, "critical"),
|
||||||
|
security: await getAlertRouting(group.id, "security"),
|
||||||
|
billing: await getAlertRouting(group.id, "billing"),
|
||||||
|
general: await getAlertRouting(group.id, "general"),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
ScanListingsSchema,
|
ScanListingsSchema,
|
||||||
BrokerListingsFilterSchema,
|
BrokerListingsFilterSchema,
|
||||||
RemovalRequestsFilterSchema,
|
RemovalRequestsFilterSchema,
|
||||||
|
EnableAdapterSchema,
|
||||||
} from "../schemas/removebrokers";
|
} from "../schemas/removebrokers";
|
||||||
import * as removebrokersService from "~/server/services/removebrokers.service";
|
import * as removebrokersService from "~/server/services/removebrokers.service";
|
||||||
|
|
||||||
export const removebrokersRouter = createTRPCRouter({
|
export const removebrokersRouter = createTRPCRouter({
|
||||||
|
// Core removal flow
|
||||||
getBrokerRegistry: protectedProcedure.query(async () => {
|
getBrokerRegistry: protectedProcedure.query(async () => {
|
||||||
return removebrokersService.getBrokerRegistry();
|
return removebrokersService.getBrokerRegistry();
|
||||||
}),
|
}),
|
||||||
@@ -47,4 +49,60 @@ export const removebrokersRouter = createTRPCRouter({
|
|||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return removebrokersService.getStats(ctx.user.id);
|
return removebrokersService.getStats(ctx.user.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Enhanced stats with per-broker success rates
|
||||||
|
getEnhancedStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return removebrokersService.getEnhancedStats(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// CAPTCHA solver
|
||||||
|
getCaptchaSolverStatus: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getCaptchaSolverStatus();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Email verification
|
||||||
|
processEmailConfirmations: protectedProcedure.mutation(async () => {
|
||||||
|
return removebrokersService.processEmailConfirmations();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Re-scan pipeline
|
||||||
|
executeReScan: protectedProcedure.mutation(async () => {
|
||||||
|
return removebrokersService.executeReScan();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getReListingStats: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getReListingStats();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Adapter health
|
||||||
|
getAdapterSystemHealth: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getAdapterSystemHealth();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getBrokenAdapters: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getBrokenAdaptersList();
|
||||||
|
}),
|
||||||
|
|
||||||
|
enableAdapter: protectedProcedure
|
||||||
|
.input(wrap(EnableAdapterSchema))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return removebrokersService.reEnableAdapter(input.brokerId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAllAdapterHealth: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getAllAdapterHealthStatus();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cost tracking
|
||||||
|
getMonthlyCosts: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getMonthlyCosts();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCostPerUser: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getCostPerUser();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCostHistory: protectedProcedure.query(async () => {
|
||||||
|
return removebrokersService.getCostHistoryData();
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ vi.mock("~/server/services/spamshield.service", () => ({
|
|||||||
getStats: vi.fn(),
|
getStats: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/services/spamshield/onnx.inference", () => ({
|
||||||
|
initSpamModel: vi.fn().mockResolvedValue(true),
|
||||||
|
getModelInfo: vi.fn().mockReturnValue({ version: "1.0.0", task: "sms-spam-classification", num_labels: 2 }),
|
||||||
|
isModelLoaded: vi.fn().mockReturnValue(true),
|
||||||
|
getThresholds: vi.fn().mockReturnValue({ strict: 0.3, moderate: 0.5, lenient: 0.7 }),
|
||||||
|
}));
|
||||||
|
|
||||||
import * as spamshieldService from "~/server/services/spamshield.service";
|
import * as spamshieldService from "~/server/services/spamshield.service";
|
||||||
|
|
||||||
const mockCheckNumber = vi.mocked(spamshieldService.checkNumberReputation);
|
const mockCheckNumber = vi.mocked(spamshieldService.checkNumberReputation);
|
||||||
@@ -137,7 +144,7 @@ describe("spamshield.classifySMS", () => {
|
|||||||
|
|
||||||
describe("spamshield.classifyCall", () => {
|
describe("spamshield.classifyCall", () => {
|
||||||
it("classifies call metadata", async () => {
|
it("classifies call metadata", async () => {
|
||||||
const result = { isSpam: false, confidence: 0.5, callerNumber: "+1234567890", matchedRule: null, reputation: null, features: { areaCode: "+12", duration: 30, timeOfDay: 14 } };
|
const result = { isSpam: false, confidence: 0.5, callerNumber: "+1234567890", matchedRule: null, reputation: null, features: { areaCode: "+12", duration: 30, timeOfDay: 14 }, flaggedByReputation: null };
|
||||||
mockClassifyCall.mockResolvedValue(result);
|
mockClassifyCall.mockResolvedValue(result);
|
||||||
const api = createCaller(null);
|
const api = createCaller(null);
|
||||||
const res = await api.classifyCall({ callerNumber: "+1234567890", duration: 30, timeOfDay: 14 });
|
const res = await api.classifyCall({ callerNumber: "+1234567890", duration: 30, timeOfDay: 14 });
|
||||||
@@ -216,3 +223,12 @@ describe("spamshield.getStats", () => {
|
|||||||
await expect(api.getStats({ period: "month" })).rejects.toThrow(TRPCError);
|
await expect(api.getStats({ period: "month" })).rejects.toThrow(TRPCError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("spamshield.modelInfo", () => {
|
||||||
|
it("returns model info publicly", async () => {
|
||||||
|
const { spamshieldRouter } = await import("../routers/spamshield");
|
||||||
|
// The router is built with mocks, so modelInfo should work
|
||||||
|
// We test the structure of the response
|
||||||
|
expect(spamshieldRouter.modelInfo).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
StatsFilterSchema,
|
StatsFilterSchema,
|
||||||
} from "../schemas/spamshield";
|
} from "../schemas/spamshield";
|
||||||
import * as spamshieldService from "~/server/services/spamshield.service";
|
import * as spamshieldService from "~/server/services/spamshield.service";
|
||||||
|
import { initSpamModel, getModelInfo, isModelLoaded, getThresholds } from "~/server/services/spamshield/onnx.inference";
|
||||||
|
|
||||||
export const spamshieldRouter = createTRPCRouter({
|
export const spamshieldRouter = createTRPCRouter({
|
||||||
checkNumber: publicProcedure
|
checkNumber: publicProcedure
|
||||||
@@ -21,7 +22,7 @@ export const spamshieldRouter = createTRPCRouter({
|
|||||||
classifySMS: publicProcedure
|
classifySMS: publicProcedure
|
||||||
.input(wrap(ClassifySMSSchema))
|
.input(wrap(ClassifySMSSchema))
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return spamshieldService.classifySMS(input.text, ctx.user?.id);
|
return spamshieldService.classifySMS(input.text, ctx.user?.id, input.threshold);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
classifyCall: publicProcedure
|
classifyCall: publicProcedure
|
||||||
@@ -73,4 +74,13 @@ export const spamshieldRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return spamshieldService.getStats(ctx.user.id, input.period);
|
return spamshieldService.getStats(ctx.user.id, input.period);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
modelInfo: publicProcedure.query(async () => {
|
||||||
|
await initSpamModel();
|
||||||
|
return {
|
||||||
|
loaded: isModelLoaded(),
|
||||||
|
...getModelInfo(),
|
||||||
|
thresholds: getThresholds(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,32 +3,40 @@ import { initTRPC, TRPCError } from "@trpc/server";
|
|||||||
import { wrap } from "@typeschema/valibot";
|
import { wrap } from "@typeschema/valibot";
|
||||||
import {
|
import {
|
||||||
CreateEnrollmentSchema,
|
CreateEnrollmentSchema,
|
||||||
|
EnrollAdditionalSampleSchema,
|
||||||
DeleteEnrollmentSchema,
|
DeleteEnrollmentSchema,
|
||||||
AnalyzeAudioSchema,
|
AnalyzeAudioSchema,
|
||||||
AnalysisFilterSchema,
|
AnalysisFilterSchema,
|
||||||
AnalysisResultSchema,
|
AnalysisResultSchema,
|
||||||
|
AnalysisFeedbackSchema,
|
||||||
JobStatusSchema,
|
JobStatusSchema,
|
||||||
} from "../schemas/voiceprint";
|
} from "../schemas/voiceprint";
|
||||||
|
|
||||||
vi.mock("~/server/services/voiceprint.service", () => ({
|
vi.mock("~/server/services/voiceprint.service", () => ({
|
||||||
getEnrollments: vi.fn(),
|
getEnrollments: vi.fn(),
|
||||||
createEnrollment: vi.fn(),
|
createEnrollment: vi.fn(),
|
||||||
|
enrollAdditionalSample: vi.fn(),
|
||||||
deleteEnrollment: vi.fn(),
|
deleteEnrollment: vi.fn(),
|
||||||
analyzeAudio: vi.fn(),
|
analyzeAudio: vi.fn(),
|
||||||
|
reportAnalysisFeedback: vi.fn(),
|
||||||
getAnalyses: vi.fn(),
|
getAnalyses: vi.fn(),
|
||||||
getAnalysisResult: vi.fn(),
|
getAnalysisResult: vi.fn(),
|
||||||
getJobStatus: vi.fn(),
|
getJobStatus: vi.fn(),
|
||||||
|
getUsageStats: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import * as voiceprintService from "~/server/services/voiceprint.service";
|
import * as voiceprintService from "~/server/services/voiceprint.service";
|
||||||
|
|
||||||
const mockGetEnrollments = vi.mocked(voiceprintService.getEnrollments);
|
const mockGetEnrollments = vi.mocked(voiceprintService.getEnrollments);
|
||||||
const mockCreateEnrollment = vi.mocked(voiceprintService.createEnrollment);
|
const mockCreateEnrollment = vi.mocked(voiceprintService.createEnrollment);
|
||||||
|
const mockEnrollAdditionalSample = vi.mocked(voiceprintService.enrollAdditionalSample);
|
||||||
const mockDeleteEnrollment = vi.mocked(voiceprintService.deleteEnrollment);
|
const mockDeleteEnrollment = vi.mocked(voiceprintService.deleteEnrollment);
|
||||||
const mockAnalyzeAudio = vi.mocked(voiceprintService.analyzeAudio);
|
const mockAnalyzeAudio = vi.mocked(voiceprintService.analyzeAudio);
|
||||||
|
const mockReportAnalysisFeedback = vi.mocked(voiceprintService.reportAnalysisFeedback);
|
||||||
const mockGetAnalyses = vi.mocked(voiceprintService.getAnalyses);
|
const mockGetAnalyses = vi.mocked(voiceprintService.getAnalyses);
|
||||||
const mockGetAnalysisResult = vi.mocked(voiceprintService.getAnalysisResult);
|
const mockGetAnalysisResult = vi.mocked(voiceprintService.getAnalysisResult);
|
||||||
const mockGetJobStatus = vi.mocked(voiceprintService.getJobStatus);
|
const mockGetJobStatus = vi.mocked(voiceprintService.getJobStatus);
|
||||||
|
const mockGetUsageStats = vi.mocked(voiceprintService.getUsageStats);
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: string; email: string; name: string | null; image: string | null;
|
id: string; email: string; name: string | null; image: string | null;
|
||||||
@@ -54,6 +62,11 @@ function createCaller(user: User | null) {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return mockCreateEnrollment(ctx.user.id, input.name, input.audioBase64);
|
return mockCreateEnrollment(ctx.user.id, input.name, input.audioBase64);
|
||||||
}),
|
}),
|
||||||
|
enrollAdditionalSample: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(EnrollAdditionalSampleSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return mockEnrollAdditionalSample(ctx.user.id, input.enrollmentId, input.audioBase64);
|
||||||
|
}),
|
||||||
deleteEnrollment: t.procedure.use(isAuthed)
|
deleteEnrollment: t.procedure.use(isAuthed)
|
||||||
.input(wrap(DeleteEnrollmentSchema))
|
.input(wrap(DeleteEnrollmentSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -64,6 +77,14 @@ function createCaller(user: User | null) {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return mockAnalyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
|
return mockAnalyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
|
||||||
}),
|
}),
|
||||||
|
reportAnalysisFeedback: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(AnalysisFeedbackSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return mockReportAnalysisFeedback(ctx.user.id, input.analysisId, {
|
||||||
|
isFalsePositive: input.isFalsePositive,
|
||||||
|
notes: input.notes,
|
||||||
|
});
|
||||||
|
}),
|
||||||
getAnalyses: t.procedure.use(isAuthed)
|
getAnalyses: t.procedure.use(isAuthed)
|
||||||
.input(wrap(AnalysisFilterSchema))
|
.input(wrap(AnalysisFilterSchema))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -79,6 +100,9 @@ function createCaller(user: User | null) {
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return mockGetJobStatus(ctx.user.id, input.jobId);
|
return mockGetJobStatus(ctx.user.id, input.jobId);
|
||||||
}),
|
}),
|
||||||
|
getUsageStats: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||||
|
return mockGetUsageStats(ctx.user.id);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const caller = t.createCallerFactory(router);
|
const caller = t.createCallerFactory(router);
|
||||||
@@ -131,6 +155,26 @@ describe("voiceprint.createEnrollment", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("voiceprint.enrollAdditionalSample", () => {
|
||||||
|
it("enrolls an additional audio sample", async () => {
|
||||||
|
const result = { id: "enr-1", enrollmentsCount: 3, enrollmentStatus: "Enrolled" };
|
||||||
|
mockEnrollAdditionalSample.mockResolvedValue(result as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const res = await api.enrollAdditionalSample({
|
||||||
|
enrollmentId: "enr-1",
|
||||||
|
audioBase64: "bW9yZS1hdWRpbw==",
|
||||||
|
});
|
||||||
|
expect(res.enrollmentsCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing enrollmentId", async () => {
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await expect(
|
||||||
|
api.enrollAdditionalSample({ enrollmentId: "", audioBase64: "dGVzdA==" }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("voiceprint.deleteEnrollment", () => {
|
describe("voiceprint.deleteEnrollment", () => {
|
||||||
it("deletes enrollment", async () => {
|
it("deletes enrollment", async () => {
|
||||||
mockDeleteEnrollment.mockResolvedValue({ id: "enr-1", isActive: false } as never);
|
mockDeleteEnrollment.mockResolvedValue({ id: "enr-1", isActive: false } as never);
|
||||||
@@ -157,6 +201,20 @@ describe("voiceprint.analyzeAudio", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("voiceprint.reportAnalysisFeedback", () => {
|
||||||
|
it("submits feedback on analysis", async () => {
|
||||||
|
const result = { id: "ana-1", userFeedback: { isFalsePositive: true } };
|
||||||
|
mockReportAnalysisFeedback.mockResolvedValue(result as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const res = await api.reportAnalysisFeedback({
|
||||||
|
analysisId: "ana-1",
|
||||||
|
isFalsePositive: true,
|
||||||
|
notes: "Not synthetic",
|
||||||
|
});
|
||||||
|
expect((res.userFeedback as { isFalsePositive: boolean }).isFalsePositive).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("voiceprint.getAnalyses", () => {
|
describe("voiceprint.getAnalyses", () => {
|
||||||
it("returns paginated analyses", async () => {
|
it("returns paginated analyses", async () => {
|
||||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||||
@@ -193,3 +251,14 @@ describe("voiceprint.getJobStatus", () => {
|
|||||||
expect(result.status).toBe("RUNNING");
|
expect(result.status).toBe("RUNNING");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("voiceprint.getUsageStats", () => {
|
||||||
|
it("returns usage statistics", async () => {
|
||||||
|
const stats = { analysesThisMonth: 5, activeEnrollments: 2 };
|
||||||
|
mockGetUsageStats.mockResolvedValue(stats);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getUsageStats();
|
||||||
|
expect(result.analysesThisMonth).toBe(5);
|
||||||
|
expect(result.activeEnrollments).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { wrap } from "@typeschema/valibot";
|
import { wrap } from "@typeschema/valibot";
|
||||||
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||||
import {
|
import {
|
||||||
CreateEnrollmentSchema,
|
CreateEnrollmentSchema,
|
||||||
|
EnrollAdditionalSampleSchema,
|
||||||
DeleteEnrollmentSchema,
|
DeleteEnrollmentSchema,
|
||||||
AnalyzeAudioSchema,
|
AnalyzeAudioSchema,
|
||||||
AnalysisFilterSchema,
|
AnalysisFilterSchema,
|
||||||
AnalysisResultSchema,
|
AnalysisResultSchema,
|
||||||
|
AnalysisFeedbackSchema,
|
||||||
JobStatusSchema,
|
JobStatusSchema,
|
||||||
|
AnalyzeCallRecordingSchema,
|
||||||
|
GetCallAnalysesSchema,
|
||||||
|
GetCallAnalysisSchema,
|
||||||
|
UpdateCallAnalysisSettingsSchema,
|
||||||
|
EmergencyHangupSchema,
|
||||||
} from "../schemas/voiceprint";
|
} from "../schemas/voiceprint";
|
||||||
import * as voiceprintService from "~/server/services/voiceprint.service";
|
import * as voiceprintService from "~/server/services/voiceprint.service";
|
||||||
|
|
||||||
@@ -21,6 +29,16 @@ export const voiceprintRouter = createTRPCRouter({
|
|||||||
return voiceprintService.createEnrollment(ctx.user.id, input.name, input.audioBase64);
|
return voiceprintService.createEnrollment(ctx.user.id, input.name, input.audioBase64);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
enrollAdditionalSample: protectedProcedure
|
||||||
|
.input(wrap(EnrollAdditionalSampleSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.enrollAdditionalSample(
|
||||||
|
ctx.user.id,
|
||||||
|
input.enrollmentId,
|
||||||
|
input.audioBase64,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
deleteEnrollment: protectedProcedure
|
deleteEnrollment: protectedProcedure
|
||||||
.input(wrap(DeleteEnrollmentSchema))
|
.input(wrap(DeleteEnrollmentSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -33,6 +51,15 @@ export const voiceprintRouter = createTRPCRouter({
|
|||||||
return voiceprintService.analyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
|
return voiceprintService.analyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
reportAnalysisFeedback: protectedProcedure
|
||||||
|
.input(wrap(AnalysisFeedbackSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.reportAnalysisFeedback(ctx.user.id, input.analysisId, {
|
||||||
|
isFalsePositive: input.isFalsePositive,
|
||||||
|
notes: input.notes,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
getAnalyses: protectedProcedure
|
getAnalyses: protectedProcedure
|
||||||
.input(wrap(AnalysisFilterSchema))
|
.input(wrap(AnalysisFilterSchema))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -50,4 +77,78 @@ export const voiceprintRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return voiceprintService.getJobStatus(ctx.user.id, input.jobId);
|
return voiceprintService.getJobStatus(ctx.user.id, input.jobId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getUsageStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return voiceprintService.getUsageStats(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- Call Recording Endpoints ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze a call recording audio file.
|
||||||
|
* Accepts base64 audio or multipart upload (via form data).
|
||||||
|
*/
|
||||||
|
analyzeCallRecording: protectedProcedure
|
||||||
|
.input(wrap(AnalyzeCallRecordingSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.analyzeCallRecording(ctx.user.id, {
|
||||||
|
audioBase64: input.audioBase64 ?? undefined,
|
||||||
|
phoneNumber: input.phoneNumber,
|
||||||
|
direction: input.direction as "incoming" | "outgoing",
|
||||||
|
duration: input.duration,
|
||||||
|
callStartedAt: new Date(input.callStartedAt),
|
||||||
|
consentState: input.consentState as "one-party" | "two-party" | "unknown" | undefined,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated call analysis history.
|
||||||
|
*/
|
||||||
|
getCallAnalyses: protectedProcedure
|
||||||
|
.input(wrap(GetCallAnalysesSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.getCallAnalyses(ctx.user.id, {
|
||||||
|
page: input.page,
|
||||||
|
limit: input.limit,
|
||||||
|
status: input.status,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single call analysis by ID.
|
||||||
|
*/
|
||||||
|
getCallAnalysis: protectedProcedure
|
||||||
|
.input(wrap(GetCallAnalysisSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.getCallAnalysis(ctx.user.id, input.callRecordingId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create call analysis settings for the user.
|
||||||
|
*/
|
||||||
|
getCallAnalysisSettings: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return voiceprintService.getCallAnalysisSettings(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update call analysis settings.
|
||||||
|
*/
|
||||||
|
updateCallAnalysisSettings: protectedProcedure
|
||||||
|
.input(wrap(UpdateCallAnalysisSettingsSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.updateCallAnalysisSettings(ctx.user.id, input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency hangup + block number when synthetic voice detected.
|
||||||
|
* Records the block action and returns instructions for the device to execute.
|
||||||
|
*/
|
||||||
|
emergencyHangup: protectedProcedure
|
||||||
|
.input(wrap(EmergencyHangupSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return voiceprintService.emergencyHangup(ctx.user.id, {
|
||||||
|
callRecordingId: input.callRecordingId,
|
||||||
|
phoneNumber: input.phoneNumber,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { object, string, minLength, optional, picklist } from "valibot";
|
import { object, string, minLength, optional, picklist, boolean } from "valibot";
|
||||||
import { returnUrlSchema } from "~/lib/url-validation";
|
import { returnUrlSchema } from "~/lib/url-validation";
|
||||||
|
|
||||||
export const CreateCheckoutSessionSchema = object({
|
export const CreateCheckoutSessionSchema = object({
|
||||||
@@ -31,3 +31,17 @@ export const UpgradeFromTrialSchema = object({
|
|||||||
plan: picklist(["basic", "plus", "premium"]),
|
plan: picklist(["basic", "plus", "premium"]),
|
||||||
returnUrl: returnUrlSchema,
|
returnUrl: returnUrlSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CreateTrialSubscriptionSchema = object({
|
||||||
|
returnUrl: returnUrlSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChangeTierSchema = object({
|
||||||
|
tier: picklist(["basic", "plus", "premium", "family_guard", "family_fortress"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateFamilyCheckoutSessionSchema = object({
|
||||||
|
tier: picklist(["family_guard", "family_fortress"]),
|
||||||
|
returnUrl: returnUrlSchema,
|
||||||
|
familyGroupId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ export const ResolveAlertSchema = object({
|
|||||||
alertId: string([minLength(1)]),
|
alertId: string([minLength(1)]),
|
||||||
resolution: picklist(["RESOLVED", "FALSE_POSITIVE"]),
|
resolution: picklist(["RESOLVED", "FALSE_POSITIVE"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FamilyThreatScoreSchema = object({
|
||||||
|
groupId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|||||||
65
web/src/server/api/schemas/family.ts
Normal file
65
web/src/server/api/schemas/family.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { object, string, email, minLength, optional, picklist, array, boolean, number } from "valibot";
|
||||||
|
|
||||||
|
export const CreateFamilyGroupSchema = object({
|
||||||
|
name: string([minLength(1)]),
|
||||||
|
planTier: optional(picklist(["family_guard", "family_fortress"])),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InviteFamilyMemberSchema = object({
|
||||||
|
email: string([email()]),
|
||||||
|
role: optional(picklist(["admin", "member"]), "member"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AcceptInvitationSchema = object({
|
||||||
|
token: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResendInvitationSchema = object({
|
||||||
|
invitationId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CancelInvitationSchema = object({
|
||||||
|
invitationId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RemoveFamilyMemberSchema = object({
|
||||||
|
userId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LeaveFamilyGroupSchema = object({
|
||||||
|
groupId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateMemberRoleSchema = object({
|
||||||
|
userId: string([minLength(1)]),
|
||||||
|
role: picklist(["owner", "admin", "member"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TransferOwnershipSchema = object({
|
||||||
|
newOwnerId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConfigureServicesSchema = object({
|
||||||
|
userId: string([minLength(1)]),
|
||||||
|
services: array(object({
|
||||||
|
service: picklist(["darkwatch", "spamshield", "removebrokers", "hometitle", "voiceprint"]),
|
||||||
|
enabled: boolean(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateAlertPreferencesSchema = object({
|
||||||
|
groupId: string([minLength(1)]),
|
||||||
|
preferences: array(object({
|
||||||
|
alertType: picklist(["critical", "security", "billing", "general"]),
|
||||||
|
channel: picklist(["email", "push", "sms"]),
|
||||||
|
enabled: boolean(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateFamilyPlanTierSchema = object({
|
||||||
|
planTier: picklist(["family_guard", "family_fortress"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MemberDetailSchema = object({
|
||||||
|
userId: string([minLength(1)]),
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { object, string, minLength, optional, number, picklist } from "valibot";
|
import { object, string, minLength, optional, number, picklist, boolean } from "valibot";
|
||||||
|
|
||||||
export const PersonalInfoSchema = object({
|
export const PersonalInfoSchema = object({
|
||||||
fullName: string([minLength(1)]),
|
fullName: string([minLength(1)]),
|
||||||
@@ -35,3 +35,17 @@ export const RemovalRequestsFilterSchema = object({
|
|||||||
page: optional(number(), 1),
|
page: optional(number(), 1),
|
||||||
limit: optional(number(), 20),
|
limit: optional(number(), 20),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const EnableAdapterSchema = object({
|
||||||
|
brokerId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReScanConfigSchema = object({
|
||||||
|
cooldownDays: optional(number(), 7),
|
||||||
|
batchSize: optional(number(), 50),
|
||||||
|
autoReSubmit: optional(boolean(), true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CostHistorySchema = object({
|
||||||
|
months: optional(number(), 6),
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const CheckNumberSchema = object({
|
|||||||
|
|
||||||
export const ClassifySMSSchema = object({
|
export const ClassifySMSSchema = object({
|
||||||
text: string([minLength(1)]),
|
text: string([minLength(1)]),
|
||||||
|
threshold: optional(picklist(["strict", "moderate", "lenient"]), "moderate"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ClassifyCallSchema = object({
|
export const ClassifyCallSchema = object({
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ export const UpdateRoleSchema = object({
|
|||||||
userId: string(),
|
userId: string(),
|
||||||
role: picklist(["owner", "admin", "member"]),
|
role: picklist(["owner", "admin", "member"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const InviteByEmailSchema = object({
|
||||||
|
email: string([email()]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AcceptInviteSchema = object({
|
||||||
|
token: string(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
optional,
|
optional,
|
||||||
number,
|
number,
|
||||||
picklist,
|
picklist,
|
||||||
|
boolean,
|
||||||
} from "valibot";
|
} from "valibot";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +30,11 @@ export const CreateEnrollmentSchema = object({
|
|||||||
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const EnrollAdditionalSampleSchema = object({
|
||||||
|
enrollmentId: string([minLength(1)]),
|
||||||
|
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
||||||
|
});
|
||||||
|
|
||||||
export const DeleteEnrollmentSchema = object({
|
export const DeleteEnrollmentSchema = object({
|
||||||
enrollmentId: string([minLength(1)]),
|
enrollmentId: string([minLength(1)]),
|
||||||
});
|
});
|
||||||
@@ -48,6 +54,51 @@ export const AnalysisResultSchema = object({
|
|||||||
analysisId: string([minLength(1)]),
|
analysisId: string([minLength(1)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AnalysisFeedbackSchema = object({
|
||||||
|
analysisId: string([minLength(1)]),
|
||||||
|
isFalsePositive: boolean(),
|
||||||
|
notes: optional(string()),
|
||||||
|
});
|
||||||
|
|
||||||
export const JobStatusSchema = object({
|
export const JobStatusSchema = object({
|
||||||
jobId: string([minLength(1)]),
|
jobId: string([minLength(1)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Call recording analysis schemas */
|
||||||
|
export const AnalyzeCallRecordingSchema = object({
|
||||||
|
/** Audio file as base64 (alternative to multipart upload) */
|
||||||
|
audioBase64: optional(string([minLength(1), maxLength(MAX_BASE64_LENGTH)])),
|
||||||
|
/** Phone number of the caller/called party */
|
||||||
|
phoneNumber: string([minLength(1)]),
|
||||||
|
/** Call direction */
|
||||||
|
direction: string(),
|
||||||
|
/** Call duration in seconds */
|
||||||
|
duration: number(),
|
||||||
|
/** Call start timestamp ISO string */
|
||||||
|
callStartedAt: string(),
|
||||||
|
/** Two-party consent state detected on device */
|
||||||
|
consentState: optional(string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetCallAnalysesSchema = object({
|
||||||
|
page: optional(number(), 1),
|
||||||
|
limit: optional(number(), 20),
|
||||||
|
status: optional(string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetCallAnalysisSchema = object({
|
||||||
|
callRecordingId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateCallAnalysisSettingsSchema = object({
|
||||||
|
callAnalysisEnabled: optional(boolean()),
|
||||||
|
autoAnalyze: optional(boolean()),
|
||||||
|
audioRetentionDays: optional(number()),
|
||||||
|
notifyOnSynthetic: optional(boolean()),
|
||||||
|
autoBlockSynthetic: optional(boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmergencyHangupSchema = object({
|
||||||
|
callRecordingId: string([minLength(1)]),
|
||||||
|
phoneNumber: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,17 +47,20 @@ describe("SubscriptionSchema", () => {
|
|||||||
status: "active",
|
status: "active",
|
||||||
current_period_start: 1700000000,
|
current_period_start: 1700000000,
|
||||||
current_period_end: 1702678400,
|
current_period_end: 1702678400,
|
||||||
cancel_at_period_end: "false",
|
trial_end: 1700500000,
|
||||||
|
cancel_at_period_end: false,
|
||||||
metadata: { userId: "user_123" },
|
metadata: { userId: "user_123" },
|
||||||
items: {
|
items: {
|
||||||
data: { price: { id: "price_basic" } },
|
data: [{ price: { id: "price_basic" } }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = safeParse(SubscriptionSchema, data);
|
const result = safeParse(SubscriptionSchema, data);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.output.current_period_start).toBe(1700000000);
|
expect(result.output.current_period_start).toBe(1700000000);
|
||||||
expect(result.output.items?.data?.price?.id).toBe("price_basic");
|
expect(result.output.items?.data?.[0]?.price?.id).toBe("price_basic");
|
||||||
|
expect(result.output.trial_end).toBe(1700500000);
|
||||||
|
expect(result.output.cancel_at_period_end).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,14 +78,17 @@ describe("SubscriptionSchema", () => {
|
|||||||
const result = safeParse(SubscriptionSchema, data);
|
const result = safeParse(SubscriptionSchema, data);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.output.cancel_at_period_end).toBe("false");
|
expect(result.output.cancel_at_period_end).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts string cancel_at_period_end", () => {
|
it("accepts boolean cancel_at_period_end", () => {
|
||||||
const data = { id: "sub_123", cancel_at_period_end: "true" };
|
const data = { id: "sub_123", cancel_at_period_end: true };
|
||||||
const result = safeParse(SubscriptionSchema, data);
|
const result = safeParse(SubscriptionSchema, data);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.output.cancel_at_period_end).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects missing required id", () => {
|
it("rejects missing required id", () => {
|
||||||
@@ -100,6 +106,23 @@ describe("SubscriptionSchema", () => {
|
|||||||
const result = safeParse(SubscriptionSchema, data);
|
const result = safeParse(SubscriptionSchema, data);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles items as array of price objects", () => {
|
||||||
|
const data = {
|
||||||
|
id: "sub_123",
|
||||||
|
items: {
|
||||||
|
data: [
|
||||||
|
{ price: { id: "price_1" } },
|
||||||
|
{ price: { id: "price_2" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = safeParse(SubscriptionSchema, data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.output.items?.data).toHaveLength(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("InvoiceSchema", () => {
|
describe("InvoiceSchema", () => {
|
||||||
@@ -112,6 +135,23 @@ describe("InvoiceSchema", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts full invoice data with amount and currency", () => {
|
||||||
|
const data = {
|
||||||
|
id: "in_123",
|
||||||
|
subscription: "sub_456",
|
||||||
|
amount_due: 1999,
|
||||||
|
currency: "usd",
|
||||||
|
status: "paid",
|
||||||
|
};
|
||||||
|
const result = safeParse(InvoiceSchema, data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.output.id).toBe("in_123");
|
||||||
|
expect(result.output.amount_due).toBe(1999);
|
||||||
|
expect(result.output.currency).toBe("usd");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts invoice without subscription (for partial invoices)", () => {
|
it("accepts invoice without subscription (for partial invoices)", () => {
|
||||||
const data = { id: "in_123" };
|
const data = { id: "in_123" };
|
||||||
const result = safeParse(InvoiceSchema, data);
|
const result = safeParse(InvoiceSchema, data);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { object, string, optional, number, type Output } from "valibot";
|
import { object, string, optional, number, array, boolean, type Output } from "valibot";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a Stripe Checkout Session object from webhook data.
|
* Validates a Stripe Checkout Session object from webhook data.
|
||||||
@@ -16,10 +16,12 @@ export const CheckoutSessionSchema = object({
|
|||||||
/**
|
/**
|
||||||
* Price item inside a Stripe Subscription.
|
* Price item inside a Stripe Subscription.
|
||||||
*/
|
*/
|
||||||
const PriceItemSchema = object({
|
const PriceObjectSchema = object({
|
||||||
price: object({
|
|
||||||
id: string(),
|
id: string(),
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
const SubscriptionItemSchema = object({
|
||||||
|
price: optional(PriceObjectSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +32,8 @@ export const SubscriptionSchema = object({
|
|||||||
status: optional(string()),
|
status: optional(string()),
|
||||||
current_period_start: optional(number()),
|
current_period_start: optional(number()),
|
||||||
current_period_end: optional(number()),
|
current_period_end: optional(number()),
|
||||||
cancel_at_period_end: optional(string(), "false"),
|
trial_end: optional(number()),
|
||||||
|
cancel_at_period_end: optional(boolean(), false),
|
||||||
metadata: optional(
|
metadata: optional(
|
||||||
object({
|
object({
|
||||||
userId: optional(string()),
|
userId: optional(string()),
|
||||||
@@ -38,7 +41,7 @@ export const SubscriptionSchema = object({
|
|||||||
),
|
),
|
||||||
items: optional(
|
items: optional(
|
||||||
object({
|
object({
|
||||||
data: optional(PriceItemSchema),
|
data: optional(array(SubscriptionItemSchema), []),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -47,7 +50,11 @@ export const SubscriptionSchema = object({
|
|||||||
* Validates a Stripe Invoice object from webhook data.
|
* Validates a Stripe Invoice object from webhook data.
|
||||||
*/
|
*/
|
||||||
export const InvoiceSchema = object({
|
export const InvoiceSchema = object({
|
||||||
|
id: optional(string()),
|
||||||
subscription: optional(string()),
|
subscription: optional(string()),
|
||||||
|
amount_due: optional(number()),
|
||||||
|
currency: optional(string()),
|
||||||
|
status: optional(string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type exports for use in billing.service.ts
|
// Type exports for use in billing.service.ts
|
||||||
|
|||||||
24
web/src/server/db/schema/attom-usage.ts
Normal file
24
web/src/server/db/schema/attom-usage.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { sqliteTable, text, real, integer, index } from "drizzle-orm/sqlite-core";
|
||||||
|
import { subscriptions } from "./subscription";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks Attom Data Solutions API usage for cost analytics and billing.
|
||||||
|
* Each row records one API call (or batch of calls for multi-property scans).
|
||||||
|
* Cost is tracked in dollars at ~$0.05–$0.10 per record lookup.
|
||||||
|
*/
|
||||||
|
export const attomApiUsage = sqliteTable("attom_api_usage", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
endpoint: text("endpoint").notNull(),
|
||||||
|
cost: real("cost").notNull(), // USD cost of this API call
|
||||||
|
propertyWatchlistItemId: text("property_watchlist_item_id"),
|
||||||
|
statusCode: integer("status_code"),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
subscriptionIdIdx: index("attom_api_usage_subscription_id_idx").on(table.subscriptionId),
|
||||||
|
userIdIdx: index("attom_api_usage_user_id_idx").on(table.userId),
|
||||||
|
createdAtIdx: index("attom_api_usage_created_at_idx").on(table.createdAt),
|
||||||
|
subscriptionCreatedAtIdx: index("attom_api_usage_sub_created_idx").on(table.subscriptionId, table.createdAt),
|
||||||
|
}));
|
||||||
@@ -9,6 +9,10 @@ export const correlationGroups = sqliteTable("correlation_groups", {
|
|||||||
status: text("status").default("ACTIVE").notNull(),
|
status: text("status").default("ACTIVE").notNull(),
|
||||||
alertCount: integer("alert_count").default(0).notNull(),
|
alertCount: integer("alert_count").default(0).notNull(),
|
||||||
summary: text("summary"),
|
summary: text("summary"),
|
||||||
|
// Human-readable narrative explaining the correlation
|
||||||
|
narrative: text("narrative"),
|
||||||
|
// Which correlation rules matched (e.g., ["RULE_1", "RULE_3"])
|
||||||
|
matchedRules: text("matched_rules", { mode: "json" }),
|
||||||
resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }),
|
resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
@@ -42,3 +46,27 @@ export const normalizedAlerts = sqliteTable("normalized_alerts", {
|
|||||||
createdAtIdx: index("normalized_alerts_created_at_idx").on(table.createdAt),
|
createdAtIdx: index("normalized_alerts_created_at_idx").on(table.createdAt),
|
||||||
userIdCreatedAtIdx: index("normalized_alerts_user_id_created_at_idx").on(table.userId, table.createdAt),
|
userIdCreatedAtIdx: index("normalized_alerts_user_id_created_at_idx").on(table.userId, table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threat score snapshots for historical trend tracking.
|
||||||
|
* Snapshots are taken each time the score is recalculated.
|
||||||
|
* Keeps last 90 days of data.
|
||||||
|
*/
|
||||||
|
export const threatScoreSnapshots = sqliteTable("threat_score_snapshots", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
score: integer("score").notNull(),
|
||||||
|
baseScore: integer("base_score").default(0).notNull(),
|
||||||
|
correlationBonus: integer("correlation_bonus").default(0).notNull(),
|
||||||
|
alertCount: integer("alert_count").default(0).notNull(),
|
||||||
|
correlationCount: integer("correlation_count").default(0).notNull(),
|
||||||
|
// JSON breakdown: { DARKWATCH: 15, SPAMSHIELD: 10, ... }
|
||||||
|
sourceBreakdown: text("source_breakdown", { mode: "json" }),
|
||||||
|
// Which rules contributed: [{ rule: "RULE_1", bonus: 30 }, ...]
|
||||||
|
ruleBreakdown: text("rule_breakdown", { mode: "json" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index("threat_score_snapshots_user_id_idx").on(table.userId),
|
||||||
|
userIdCreatedAtIdx: index("threat_score_snapshots_user_id_created_at_idx").on(table.userId, table.createdAt),
|
||||||
|
createdAtIdx: index("threat_score_snapshots_created_at_idx").on(table.createdAt),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, integer, real, uniqueIndex, index } from "drizzle-orm/sqlite-core";
|
||||||
import { subscriptions } from "./subscription";
|
import { subscriptions } from "./subscription";
|
||||||
|
|
||||||
export const watchlistItems = sqliteTable("watchlist_items", {
|
export const watchlistItems = sqliteTable("watchlist_items", {
|
||||||
@@ -38,3 +38,106 @@ export const exposures = sqliteTable("exposures", {
|
|||||||
severityIdx: index("exposures_severity_idx").on(table.severity),
|
severityIdx: index("exposures_severity_idx").on(table.severity),
|
||||||
detectedAtIdx: index("exposures_detected_at_idx").on(table.detectedAt),
|
detectedAtIdx: index("exposures_detected_at_idx").on(table.detectedAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const scanCosts = sqliteTable("scan_costs", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
||||||
|
source: text("source").notNull(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
apiCalls: integer("api_calls").notNull().default(0),
|
||||||
|
estimatedCost: real("estimated_cost").notNull().default(0),
|
||||||
|
cacheHits: integer("cache_hits").notNull().default(0),
|
||||||
|
scanDurationMs: integer("scan_duration_ms").notNull().default(0),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
subscriptionIdIdx: index("scan_costs_subscription_id_idx").on(table.subscriptionId),
|
||||||
|
sourceIdx: index("scan_costs_source_idx").on(table.source),
|
||||||
|
createdAtIdx: index("scan_costs_created_at_idx").on(table.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scan History — per-scan metrics for dashboard and threat score
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const scanHistory = sqliteTable("scan_history", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
||||||
|
scanId: text("scan_id").notNull(),
|
||||||
|
status: text("status").default("running").notNull(),
|
||||||
|
sourcesChecked: integer("sources_checked").notNull().default(0),
|
||||||
|
totalSources: integer("total_sources").notNull().default(0),
|
||||||
|
exposuresFound: integer("exposures_found").notNull().default(0),
|
||||||
|
newExposures: integer("new_exposures").notNull().default(0),
|
||||||
|
alertsGenerated: integer("alerts_generated").notNull().default(0),
|
||||||
|
alertsSuppressed: integer("alerts_suppressed").notNull().default(0),
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
failedSources: text("failed_sources", { mode: "json" }),
|
||||||
|
threatScore: real("threat_score"),
|
||||||
|
startedAt: integer("started_at", { mode: "timestamp_ms" }),
|
||||||
|
completedAt: integer("completed_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
subscriptionIdIdx: index("scan_history_subscription_id_idx").on(table.subscriptionId),
|
||||||
|
scanIdIdx: index("scan_history_scan_id_idx").on(table.scanId),
|
||||||
|
createdAtIdx: index("scan_history_created_at_idx").on(table.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert Cooldowns — prevents duplicate alerts for same (userId, alertType, source)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const alertCooldowns = sqliteTable("alert_cooldowns", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text("user_id").notNull().references(() => { const { users } = require("./auth"); return users.id; }),
|
||||||
|
alertType: text("alert_type").notNull(),
|
||||||
|
source: text("source").notNull(),
|
||||||
|
exposureId: text("exposure_id").references(() => exposures.id),
|
||||||
|
lastAlertSentAt: integer("last_alert_sent_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
cooldownHours: integer("cooldown_hours").notNull().default(24),
|
||||||
|
lastSeverity: text("last_severity").default("info").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
userAlertSourceUnique: uniqueIndex("alert_cooldowns_user_alert_source_unique").on(table.userId, table.alertType, table.source),
|
||||||
|
userIdIdx: index("alert_cooldowns_user_id_idx").on(table.userId),
|
||||||
|
lastAlertSentAtIdx: index("alert_cooldowns_last_sent_at_idx").on(table.lastAlertSentAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Digest Alerts — batches info-level alerts for daily/weekly summary emails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const digestAlerts = sqliteTable("digest_alerts", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text("user_id").notNull().references(() => { const { users } = require("./auth"); return users.id; }),
|
||||||
|
alertId: text("alert_id").notNull().references(() => { const { alerts } = require("./alerts"); return alerts.id; }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
severity: text("severity").default("info").notNull(),
|
||||||
|
source: text("source").notNull(),
|
||||||
|
scheduledDigestDate: integer("scheduled_digest_date", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
sent: integer("sent", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
sentAt: integer("sent_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdDigestDateIdx: index("digest_alerts_user_date_idx").on(table.userId, table.scheduledDigestDate),
|
||||||
|
alertIdIdx: index("digest_alerts_alert_id_idx").on(table.alertId),
|
||||||
|
sentIdx: index("digest_alerts_sent_idx").on(table.sent),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scan Queue — tracks pending scans when concurrent limit is reached
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const scanQueue = sqliteTable("scan_queue", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id").notNull().references(() => { const { users } = require("./auth"); return users.id; }),
|
||||||
|
position: integer("position").notNull().default(0),
|
||||||
|
requestedAt: integer("requested_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
subscriptionIdIdx: index("scan_queue_subscription_id_idx").on(table.subscriptionId),
|
||||||
|
userIdIdx: index("scan_queue_user_id_idx").on(table.userId),
|
||||||
|
positionIdx: index("scan_queue_position_idx").on(table.position),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ export const userRoleValues = ["user", "family_admin", "family_member", "support
|
|||||||
export const deviceTypeValues = ["mobile", "web", "desktop"] as const;
|
export const deviceTypeValues = ["mobile", "web", "desktop"] as const;
|
||||||
export const platformValues = ["ios", "android", "web"] as const;
|
export const platformValues = ["ios", "android", "web"] as const;
|
||||||
export const familyMemberRoleValues = ["owner", "admin", "member"] as const;
|
export const familyMemberRoleValues = ["owner", "admin", "member"] as const;
|
||||||
export const subscriptionTierValues = ["basic", "plus", "premium"] as const;
|
export const familyPlanTierValues = ["family_guard", "family_fortress"] as const;
|
||||||
export const subscriptionStatusValues = ["active", "past_due", "canceled", "unpaid", "trialing"] as const;
|
export const familyMemberStatusValues = ["pending", "active", "removed"] as const;
|
||||||
|
export const familyServiceValues = ["darkwatch", "spamshield", "removebrokers", "hometitle", "voiceprint"] as const;
|
||||||
|
export const familyAlertTypeValues = ["critical", "security", "billing", "general"] as const;
|
||||||
|
export const subscriptionTierValues = ["basic", "plus", "premium", "family_guard", "family_fortress"] as const;
|
||||||
|
export const subscriptionStatusValues = ["active", "past_due", "canceled", "unpaid", "trialing", "paused", "incomplete", "incomplete_expired"] as const;
|
||||||
export const watchlistTypeValues = ["email", "phoneNumber", "ssn", "address", "domain"] as const;
|
export const watchlistTypeValues = ["email", "phoneNumber", "ssn", "address", "domain"] as const;
|
||||||
export const exposureSourceValues = ["hibp", "securityTrails", "censys", "darkWebForum", "shodan", "honeypot"] as const;
|
export const exposureSourceValues = ["hibp", "securityTrails", "censys", "darkWebForum", "shodan", "honeypot"] as const;
|
||||||
export const exposureSeverityValues = ["info", "warning", "critical"] as const;
|
export const exposureSeverityValues = ["info", "warning", "critical"] as const;
|
||||||
@@ -59,3 +63,7 @@ export type BrokerCategory = typeof brokerCategoryValues[number];
|
|||||||
export type RemovalMethod = typeof removalMethodValues[number];
|
export type RemovalMethod = typeof removalMethodValues[number];
|
||||||
export type RemovalStatus = typeof removalStatusValues[number];
|
export type RemovalStatus = typeof removalStatusValues[number];
|
||||||
export type InvitationStatus = typeof invitationStatusValues[number];
|
export type InvitationStatus = typeof invitationStatusValues[number];
|
||||||
|
export type FamilyPlanTier = typeof familyPlanTierValues[number];
|
||||||
|
export type FamilyMemberStatus = typeof familyMemberStatusValues[number];
|
||||||
|
export type FamilyService = typeof familyServiceValues[number];
|
||||||
|
export type FamilyAlertType = typeof familyAlertTypeValues[number];
|
||||||
|
|||||||
40
web/src/server/db/schema/family.ts
Normal file
40
web/src/server/db/schema/family.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core";
|
||||||
|
import { familyGroupMembers } from "./subscription";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-member service configuration.
|
||||||
|
* The primary account holder assigns which services each member gets.
|
||||||
|
* Default: all members get darkwatch + spamshield + removebrokers.
|
||||||
|
* HomeTitle and VoicePrint limited by property/voice enrollment slots.
|
||||||
|
*/
|
||||||
|
export const familyMemberServices = sqliteTable("family_member_services", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
memberId: text("member_id").notNull().references(() => familyGroupMembers.id, { onDelete: "cascade" }),
|
||||||
|
service: text("service").notNull(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
|
||||||
|
configuredBy: text("configured_by").notNull(),
|
||||||
|
configuredAt: integer("configured_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
memberServiceUnique: uniqueIndex("family_member_services_member_service_unique").on(table.memberId, table.service),
|
||||||
|
memberIdIdx: index("family_member_services_member_id_idx").on(table.memberId),
|
||||||
|
serviceIdx: index("family_member_services_service_idx").on(table.service),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-member alert notification preferences.
|
||||||
|
* Members can opt into/off specific alert types and channels.
|
||||||
|
*/
|
||||||
|
export const familyMemberAlertPreferences = sqliteTable("family_member_alert_preferences", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
memberId: text("member_id").notNull().references(() => familyGroupMembers.id, { onDelete: "cascade" }),
|
||||||
|
alertType: text("alert_type").notNull(),
|
||||||
|
channel: text("channel").notNull(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
memberChannelTypeUnique: uniqueIndex("family_member_alert_prefs_member_channel_type_unique").on(table.memberId, table.channel, table.alertType),
|
||||||
|
memberIdIdx: index("family_member_alert_prefs_member_id_idx").on(table.memberId),
|
||||||
|
}));
|
||||||
@@ -10,8 +10,10 @@ export * from "./correlation";
|
|||||||
export * from "./reports";
|
export * from "./reports";
|
||||||
export * from "./marketing";
|
export * from "./marketing";
|
||||||
export * from "./hometitle";
|
export * from "./hometitle";
|
||||||
|
export * from "./attom-usage";
|
||||||
export * from "./removebrokers";
|
export * from "./removebrokers";
|
||||||
export * from "./invitation";
|
export * from "./invitation";
|
||||||
|
export * from "./family";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
export * from "./report-schedules";
|
export * from "./report-schedules";
|
||||||
export * from "./relations";
|
export * from "./relations";
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ export const invitations = sqliteTable("invitations", {
|
|||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
role: text("role").default("member").notNull(),
|
role: text("role").default("member").notNull(),
|
||||||
invitedBy: text("invited_by").notNull().references(() => users.id),
|
invitedBy: text("invited_by").notNull().references(() => users.id),
|
||||||
|
token: text("token"),
|
||||||
status: text("status").default("pending").notNull(),
|
status: text("status").default("pending").notNull(),
|
||||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
remindedAt: integer("reminded_at", { mode: "timestamp_ms" }),
|
||||||
|
acceptedAt: integer("accepted_at", { mode: "timestamp_ms" }),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import { deviceTokens } from "./auth";
|
|||||||
import { notificationPreferences } from "./notifications";
|
import { notificationPreferences } from "./notifications";
|
||||||
import { familyGroups, familyGroupMembers, subscriptions } from "./subscription";
|
import { familyGroups, familyGroupMembers, subscriptions } from "./subscription";
|
||||||
import { invitations } from "./invitation";
|
import { invitations } from "./invitation";
|
||||||
import { watchlistItems, exposures } from "./darkwatch";
|
import { familyMemberServices, familyMemberAlertPreferences } from "./family";
|
||||||
|
import { watchlistItems, exposures, scanCosts, scanHistory, alertCooldowns, digestAlerts, scanQueue } from "./darkwatch";
|
||||||
import { alerts } from "./alerts";
|
import { alerts } from "./alerts";
|
||||||
import { voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults } from "./voiceprint";
|
import { voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults } from "./voiceprint";
|
||||||
import { spamFeedback, spamRules } from "./spamshield";
|
import { spamFeedback, spamRules } from "./spamshield";
|
||||||
import { normalizedAlerts, correlationGroups } from "./correlation";
|
import { normalizedAlerts, correlationGroups, threatScoreSnapshots } from "./correlation";
|
||||||
import { securityReports } from "./reports";
|
import { securityReports } from "./reports";
|
||||||
import { reportSchedules } from "./report-schedules";
|
import { reportSchedules } from "./report-schedules";
|
||||||
import { propertyWatchlistItems, propertySnapshots, propertyChanges } from "./hometitle";
|
import { propertyWatchlistItems, propertySnapshots, propertyChanges } from "./hometitle";
|
||||||
|
import { attomApiUsage } from "./attom-usage";
|
||||||
import { infoBrokers, removalRequests, brokerListings } from "./removebrokers";
|
import { infoBrokers, removalRequests, brokerListings } from "./removebrokers";
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ one, many }) => ({
|
export const usersRelations = relations(users, ({ one, many }) => ({
|
||||||
@@ -32,10 +34,13 @@ export const usersRelations = relations(users, ({ one, many }) => ({
|
|||||||
spamRules: many(spamRules),
|
spamRules: many(spamRules),
|
||||||
normalizedAlerts: many(normalizedAlerts),
|
normalizedAlerts: many(normalizedAlerts),
|
||||||
correlationGroups: many(correlationGroups),
|
correlationGroups: many(correlationGroups),
|
||||||
|
threatScoreSnapshots: many(threatScoreSnapshots),
|
||||||
securityReports: many(securityReports),
|
securityReports: many(securityReports),
|
||||||
reportSchedules: many(reportSchedules),
|
reportSchedules: many(reportSchedules),
|
||||||
analysisJobs: many(analysisJobs),
|
analysisJobs: many(analysisJobs),
|
||||||
notificationPreferences: one(notificationPreferences),
|
notificationPreferences: one(notificationPreferences),
|
||||||
|
alertCooldowns: many(alertCooldowns),
|
||||||
|
digestAlerts: many(digestAlerts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||||
@@ -62,9 +67,11 @@ export const invitationsRelations = relations(invitations, ({ one }) => ({
|
|||||||
inviter: one(users, { fields: [invitations.invitedBy], references: [users.id] }),
|
inviter: one(users, { fields: [invitations.invitedBy], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const familyGroupMembersRelations = relations(familyGroupMembers, ({ one }) => ({
|
export const familyGroupMembersRelations = relations(familyGroupMembers, ({ one, many }) => ({
|
||||||
group: one(familyGroups, { fields: [familyGroupMembers.groupId], references: [familyGroups.id] }),
|
group: one(familyGroups, { fields: [familyGroupMembers.groupId], references: [familyGroups.id] }),
|
||||||
user: one(users, { fields: [familyGroupMembers.userId], references: [users.id] }),
|
user: one(users, { fields: [familyGroupMembers.userId], references: [users.id] }),
|
||||||
|
services: many(familyMemberServices),
|
||||||
|
alertPreferences: many(familyMemberAlertPreferences),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const subscriptionsRelations = relations(subscriptions, ({ one, many }) => ({
|
export const subscriptionsRelations = relations(subscriptions, ({ one, many }) => ({
|
||||||
@@ -76,6 +83,10 @@ export const subscriptionsRelations = relations(subscriptions, ({ one, many }) =
|
|||||||
propertyWatchlistItems: many(propertyWatchlistItems),
|
propertyWatchlistItems: many(propertyWatchlistItems),
|
||||||
removalRequests: many(removalRequests),
|
removalRequests: many(removalRequests),
|
||||||
brokerListings: many(brokerListings),
|
brokerListings: many(brokerListings),
|
||||||
|
attomApiUsage: many(attomApiUsage),
|
||||||
|
scanCosts: many(scanCosts),
|
||||||
|
scanHistory: many(scanHistory),
|
||||||
|
scanQueueItems: many(scanQueue),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const watchlistItemsRelations = relations(watchlistItems, ({ one, many }) => ({
|
export const watchlistItemsRelations = relations(watchlistItems, ({ one, many }) => ({
|
||||||
@@ -89,6 +100,26 @@ export const exposuresRelations = relations(exposures, ({ one, many }) => ({
|
|||||||
alerts: many(alerts),
|
alerts: many(alerts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const scanCostsRelations = relations(scanCosts, ({ one }) => ({
|
||||||
|
subscription: one(subscriptions, { fields: [scanCosts.subscriptionId], references: [subscriptions.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const scanHistoryRelations = relations(scanHistory, ({ one }) => ({
|
||||||
|
subscription: one(subscriptions, { fields: [scanHistory.subscriptionId], references: [subscriptions.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const alertCooldownsRelations = relations(alertCooldowns, ({ one }) => ({
|
||||||
|
exposure: one(exposures, { fields: [alertCooldowns.exposureId], references: [exposures.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const digestAlertsRelations = relations(digestAlerts, ({ one }) => ({
|
||||||
|
alert: one(alerts, { fields: [digestAlerts.alertId], references: [alerts.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const scanQueueRelations = relations(scanQueue, ({ one }) => ({
|
||||||
|
subscription: one(subscriptions, { fields: [scanQueue.subscriptionId], references: [subscriptions.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const alertsRelations = relations(alerts, ({ one }) => ({
|
export const alertsRelations = relations(alerts, ({ one }) => ({
|
||||||
subscription: one(subscriptions, { fields: [alerts.subscriptionId], references: [subscriptions.id] }),
|
subscription: one(subscriptions, { fields: [alerts.subscriptionId], references: [subscriptions.id] }),
|
||||||
user: one(users, { fields: [alerts.userId], references: [users.id] }),
|
user: one(users, { fields: [alerts.userId], references: [users.id] }),
|
||||||
@@ -132,6 +163,10 @@ export const correlationGroupsRelations = relations(correlationGroups, ({ one, m
|
|||||||
alerts: many(normalizedAlerts),
|
alerts: many(normalizedAlerts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const threatScoreSnapshotsRelations = relations(threatScoreSnapshots, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [threatScoreSnapshots.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const securityReportsRelations = relations(securityReports, ({ one }) => ({
|
export const securityReportsRelations = relations(securityReports, ({ one }) => ({
|
||||||
user: one(users, { fields: [securityReports.userId], references: [users.id] }),
|
user: one(users, { fields: [securityReports.userId], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
@@ -170,3 +205,15 @@ export const brokerListingsRelations = relations(brokerListings, ({ one }) => ({
|
|||||||
removalRequest: one(removalRequests, { fields: [brokerListings.removalRequestId], references: [removalRequests.id] }),
|
removalRequest: one(removalRequests, { fields: [brokerListings.removalRequestId], references: [removalRequests.id] }),
|
||||||
subscription: one(subscriptions, { fields: [brokerListings.subscriptionId], references: [subscriptions.id] }),
|
subscription: one(subscriptions, { fields: [brokerListings.subscriptionId], references: [subscriptions.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const attomApiUsageRelations = relations(attomApiUsage, ({ one }) => ({
|
||||||
|
subscription: one(subscriptions, { fields: [attomApiUsage.subscriptionId], references: [subscriptions.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const familyMemberServicesRelations = relations(familyMemberServices, ({ one }) => ({
|
||||||
|
member: one(familyGroupMembers, { fields: [familyMemberServices.memberId], references: [familyGroupMembers.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const familyMemberAlertPreferencesRelations = relations(familyMemberAlertPreferences, ({ one }) => ({
|
||||||
|
member: one(familyGroupMembers, { fields: [familyMemberAlertPreferences.memberId], references: [familyGroupMembers.id] }),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -44,6 +44,107 @@ export const removalRequests = sqliteTable("removal_requests", {
|
|||||||
subscriptionIdStatusIdx: index("removal_requests_sub_id_status_idx").on(table.subscriptionId, table.status),
|
subscriptionIdStatusIdx: index("removal_requests_sub_id_status_idx").on(table.subscriptionId, table.status),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const captchaEvents = sqliteTable("captcha_events", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
removalRequestId: text("removal_request_id").references(() => removalRequests.id, { onDelete: "set null" }),
|
||||||
|
brokerId: text("broker_id").notNull().references(() => infoBrokers.id, { onDelete: "cascade" }),
|
||||||
|
captchaType: text("captcha_type").notNull(), // recaptcha_v2, recaptcha_v3, hcaptcha, image_challenge, turnstile
|
||||||
|
status: text("status").notNull().default("detected"), // detected, solving, solved, failed, escalated
|
||||||
|
solverProvider: text("solver_provider"), // 2captcha, anticaptcha, manual
|
||||||
|
solveAttempts: integer("solve_attempts").default(0).notNull(),
|
||||||
|
costInCents: integer("cost_in_cents"),
|
||||||
|
error: text("error"),
|
||||||
|
solvedAt: integer("solved_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
removalRequestIdIdx: index("captcha_events_removal_request_id_idx").on(table.removalRequestId),
|
||||||
|
brokerIdIdx: index("captcha_events_broker_id_idx").on(table.brokerId),
|
||||||
|
statusIdx: index("captcha_events_status_idx").on(table.status),
|
||||||
|
createdAtIdx: index("captcha_events_created_at_idx").on(table.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const emailVerifications = sqliteTable("email_verifications", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
removalRequestId: text("removal_request_id").references(() => removalRequests.id, { onDelete: "set null" }),
|
||||||
|
brokerId: text("broker_id").notNull().references(() => infoBrokers.id, { onDelete: "cascade" }),
|
||||||
|
emailTo: text("email_to").notNull(),
|
||||||
|
emailFrom: text("email_from"),
|
||||||
|
emailSubject: text("email_subject"),
|
||||||
|
confirmationUrl: text("confirmation_url"),
|
||||||
|
status: text("status").notNull().default("pending"), // pending, confirmed, expired, failed
|
||||||
|
clickedAt: integer("clicked_at", { mode: "timestamp_ms" }),
|
||||||
|
confirmedAt: integer("confirmed_at", { mode: "timestamp_ms" }),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
removalRequestIdIdx: index("email_verifications_removal_request_id_idx").on(table.removalRequestId),
|
||||||
|
brokerIdIdx: index("email_verifications_broker_id_idx").on(table.brokerId),
|
||||||
|
statusIdx: index("email_verifications_status_idx").on(table.status),
|
||||||
|
createdAtIdx: index("email_verifications_created_at_idx").on(table.createdAt),
|
||||||
|
emailToIdx: index("email_verifications_email_to_idx").on(table.emailTo),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const reScanResults = sqliteTable("re_scan_results", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
||||||
|
brokerId: text("broker_id").notNull(),
|
||||||
|
removalRequestId: text("removal_request_id").references(() => removalRequests.id, { onDelete: "set null" }),
|
||||||
|
wasRemoved: integer("was_removed", { mode: "boolean" }).notNull(),
|
||||||
|
isReListed: integer("is_re_listed", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
profileUrl: text("profile_url"),
|
||||||
|
scanType: text("scan_type").notNull(), // initial_scan, status_check, weekly_rescan, re_listing_detected
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
subscriptionIdIdx: index("re_scan_results_subscription_id_idx").on(table.subscriptionId),
|
||||||
|
brokerIdIdx: index("re_scan_results_broker_id_idx").on(table.brokerId),
|
||||||
|
isReListedIdx: index("re_scan_results_is_re_listed_idx").on(table.isReListed),
|
||||||
|
scanTypeIdx: index("re_scan_results_scan_type_idx").on(table.scanType),
|
||||||
|
createdAtIdx: index("re_scan_results_created_at_idx").on(table.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const adapterHealth = sqliteTable("adapter_health", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
brokerId: text("broker_id").notNull().references(() => infoBrokers.id, { onDelete: "cascade" }),
|
||||||
|
brokerName: text("broker_name").notNull(),
|
||||||
|
status: text("status").notNull().default("healthy"), // healthy, degraded, broken, disabled
|
||||||
|
successCount: integer("success_count").default(0).notNull(),
|
||||||
|
failureCount: integer("failure_count").default(0).notNull(),
|
||||||
|
lastSuccessAt: integer("last_success_at", { mode: "timestamp_ms" }),
|
||||||
|
lastFailureAt: integer("last_failure_at", { mode: "timestamp_ms" }),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
failureRate24h: integer("failure_rate_24h"),
|
||||||
|
totalOps24h: integer("total_ops_24h").default(0),
|
||||||
|
isAutoDisabled: integer("is_auto_disabled", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
brokerIdIdx: index("adapter_health_broker_id_idx").on(table.brokerId),
|
||||||
|
statusIdx: index("adapter_health_status_idx").on(table.status),
|
||||||
|
isAutoDisabledIdx: index("adapter_health_is_auto_disabled_idx").on(table.isAutoDisabled),
|
||||||
|
failureRate24hIdx: index("adapter_health_failure_rate_24h_idx").on(table.failureRate24h),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const costTracking = sqliteTable("cost_tracking", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
subscriptionId: text("subscription_id").references(() => subscriptions.id, { onDelete: "set null" }),
|
||||||
|
proxyProvider: text("proxy_provider"),
|
||||||
|
captchaSolver: text("captcha_solver"),
|
||||||
|
proxyRequests: integer("proxy_requests").default(0).notNull(),
|
||||||
|
captchaSolves: integer("captcha_solves").default(0).notNull(),
|
||||||
|
captchaCostCents: integer("captcha_cost_cents").default(0).notNull(),
|
||||||
|
proxyCostCents: integer("proxy_cost_cents").default(0).notNull(),
|
||||||
|
totalCostCents: integer("total_cost_cents").default(0).notNull(),
|
||||||
|
periodStart: integer("period_start", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
periodEnd: integer("period_end", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
subscriptionIdIdx: index("cost_tracking_subscription_id_idx").on(table.subscriptionId),
|
||||||
|
periodStartIdx: index("cost_tracking_period_start_idx").on(table.periodStart),
|
||||||
|
periodEndIdx: index("cost_tracking_period_end_idx").on(table.periodEnd),
|
||||||
|
}));
|
||||||
|
|
||||||
export const brokerListings = sqliteTable("broker_listings", {
|
export const brokerListings = sqliteTable("broker_listings", {
|
||||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
subscriptionId: text("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -34,3 +34,17 @@ export const spamRules = sqliteTable("spam_rules", {
|
|||||||
isGlobalIdx: index("spam_rules_is_global_idx").on(table.isGlobal),
|
isGlobalIdx: index("spam_rules_is_global_idx").on(table.isGlobal),
|
||||||
ruleTypeIdx: index("spam_rules_rule_type_idx").on(table.ruleType),
|
ruleTypeIdx: index("spam_rules_rule_type_idx").on(table.ruleType),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const reputationLookupUsage = sqliteTable("reputation_lookup_usage", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
phoneNumberHash: text("phone_number_hash").notNull(),
|
||||||
|
lookupType: text("lookup_type").notNull(),
|
||||||
|
cost: real("cost").notNull(),
|
||||||
|
metadata: text("metadata", { mode: "json" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index("reputation_lookup_usage_user_id_idx").on(table.userId),
|
||||||
|
createdAtIdx: index("reputation_lookup_usage_created_at_idx").on(table.createdAt),
|
||||||
|
lookupTypeIdx: index("reputation_lookup_usage_lookup_type_idx").on(table.lookupType),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const familyGroups = sqliteTable("family_groups", {
|
|||||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
ownerId: text("owner_id").notNull().references(() => users.id),
|
ownerId: text("owner_id").notNull().references(() => users.id),
|
||||||
|
planTier: text("plan_tier"),
|
||||||
|
maxMembers: integer("max_members"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -17,6 +19,8 @@ export const familyGroupMembers = sqliteTable("family_group_members", {
|
|||||||
groupId: text("group_id").notNull().references(() => familyGroups.id, { onDelete: "cascade" }),
|
groupId: text("group_id").notNull().references(() => familyGroups.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
role: text("role").default("member").notNull(),
|
role: text("role").default("member").notNull(),
|
||||||
|
status: text("status").default("active").notNull(),
|
||||||
|
isMinor: integer("is_minor", { mode: "boolean" }).default(false).notNull(),
|
||||||
joinedAt: integer("joined_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
joinedAt: integer("joined_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
@@ -31,11 +35,14 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
familyGroupId: text("family_group_id").references(() => familyGroups.id),
|
familyGroupId: text("family_group_id").references(() => familyGroups.id),
|
||||||
stripeId: text("stripe_id").unique(),
|
stripeId: text("stripe_id").unique(),
|
||||||
|
stripePriceId: text("stripe_price_id"),
|
||||||
tier: text("tier").default("basic").notNull(),
|
tier: text("tier").default("basic").notNull(),
|
||||||
status: text("status").default("active").notNull(),
|
status: text("status").default("active").notNull(),
|
||||||
currentPeriodStart: integer("current_period_start", { mode: "timestamp_ms" }).notNull(),
|
currentPeriodStart: integer("current_period_start", { mode: "timestamp_ms" }),
|
||||||
currentPeriodEnd: integer("current_period_end", { mode: "timestamp_ms" }).notNull(),
|
currentPeriodEnd: integer("current_period_end", { mode: "timestamp_ms" }),
|
||||||
|
trialEnd: integer("trial_end", { mode: "timestamp_ms" }),
|
||||||
cancelAtPeriodEnd: integer("cancel_at_period_end", { mode: "boolean" }).default(false).notNull(),
|
cancelAtPeriodEnd: integer("cancel_at_period_end", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
defaultPaymentMethodLast4: text("default_payment_method_last4"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -43,6 +50,7 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
familyGroupIdIdx: index("subscriptions_family_group_id_idx").on(table.familyGroupId),
|
familyGroupIdIdx: index("subscriptions_family_group_id_idx").on(table.familyGroupId),
|
||||||
stripeIdIdx: index("subscriptions_stripe_id_idx").on(table.stripeId),
|
stripeIdIdx: index("subscriptions_stripe_id_idx").on(table.stripeId),
|
||||||
tierIdx: index("subscriptions_tier_idx").on(table.tier),
|
tierIdx: index("subscriptions_tier_idx").on(table.tier),
|
||||||
|
statusIdx: index("subscriptions_status_idx").on(table.status),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const featureTrials = sqliteTable("feature_trials", {
|
export const featureTrials = sqliteTable("feature_trials", {
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ export const voiceEnrollments = sqliteTable("voice_enrollments", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
voiceHash: text("voice_hash").notNull(),
|
voiceHash: text("voice_hash").notNull(),
|
||||||
audioMetadata: text("audio_metadata", { mode: "json" }),
|
audioMetadata: text("audio_metadata", { mode: "json" }),
|
||||||
|
/** Azure Speaker Recognition profile ID for verification */
|
||||||
|
azureProfileId: text("azure_profile_id"),
|
||||||
|
/** Azure enrollment status: Enrolling / Enrolled / Training */
|
||||||
|
azureEnrollmentStatus: text("azure_enrollment_status"),
|
||||||
|
/** Number of audio samples enrolled in Azure */
|
||||||
|
enrollmentSampleCount: integer("enrollment_sample_count").default(0),
|
||||||
isActive: integer("is_active", { mode: "boolean" }).default(true).notNull(),
|
isActive: integer("is_active", { mode: "boolean" }).default(true).notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
userIdIdx: index("voice_enrollments_user_id_idx").on(table.userId),
|
userIdIdx: index("voice_enrollments_user_id_idx").on(table.userId),
|
||||||
voiceHashIdx: index("voice_enrollments_voice_hash_idx").on(table.voiceHash),
|
voiceHashIdx: index("voice_enrollments_voice_hash_idx").on(table.voiceHash),
|
||||||
|
azureProfileIdIdx: index("voice_enrollments_azure_profile_id_idx").on(table.azureProfileId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const voiceAnalyses = sqliteTable("voice_analyses", {
|
export const voiceAnalyses = sqliteTable("voice_analyses", {
|
||||||
@@ -24,6 +31,8 @@ export const voiceAnalyses = sqliteTable("voice_analyses", {
|
|||||||
confidence: real("confidence").notNull(),
|
confidence: real("confidence").notNull(),
|
||||||
analysisResult: text("analysis_result", { mode: "json" }).notNull(),
|
analysisResult: text("analysis_result", { mode: "json" }).notNull(),
|
||||||
audioUrl: text("audio_url").notNull(),
|
audioUrl: text("audio_url").notNull(),
|
||||||
|
/** User feedback: null = no feedback, true = false positive, false = false negative */
|
||||||
|
userFeedback: text("user_feedback", { mode: "json" }),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
userIdIdx: index("voice_analyses_user_id_idx").on(table.userId),
|
userIdIdx: index("voice_analyses_user_id_idx").on(table.userId),
|
||||||
@@ -46,6 +55,63 @@ export const analysisJobs = sqliteTable("analysis_jobs", {
|
|||||||
createdAtIdx: index("analysis_jobs_created_at_idx").on(table.createdAt),
|
createdAtIdx: index("analysis_jobs_created_at_idx").on(table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const callRecordings = sqliteTable("call_recordings", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
/** Phone number of the caller/called party (E.164 format) */
|
||||||
|
phoneNumber: text("phone_number").notNull(),
|
||||||
|
/** Call direction: incoming or outgoing */
|
||||||
|
direction: text("direction", { enum: ["incoming", "outgoing"] }).notNull(),
|
||||||
|
/** Duration of the call in seconds */
|
||||||
|
duration: integer("duration_seconds").notNull(),
|
||||||
|
/** Call start timestamp */
|
||||||
|
callStartedAt: integer("call_started_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
/** Path to the recorded audio file on disk */
|
||||||
|
audioFilePath: text("audio_file_path"),
|
||||||
|
/** Hash of the recorded audio for deduplication */
|
||||||
|
audioHash: text("audio_hash"),
|
||||||
|
/** Whether recording was enabled for this call */
|
||||||
|
wasRecorded: integer("was_recorded", { mode: "boolean" }).default(true).notNull(),
|
||||||
|
/** Two-party consent state at the time of recording */
|
||||||
|
consentState: text("consent_state", { enum: ["one-party", "two-party", "unknown"] }).default("unknown"),
|
||||||
|
/** Analysis verdict once processed: NATURAL, SYNTHETIC, UNCERTAIN, PENDING */
|
||||||
|
analysisStatus: text("analysis_status", { enum: ["PENDING", "PROCESSING", "NATURAL", "SYNTHETIC", "UNCERTAIN", "FAILED"] }).default("PENDING").notNull(),
|
||||||
|
/** FK to the voice analysis result once complete */
|
||||||
|
analysisId: text("analysis_id").references(() => voiceAnalyses.id),
|
||||||
|
/** Whether the audio file has been deleted after analysis */
|
||||||
|
audioDeleted: integer("audio_deleted", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
/** When the audio file will be auto-deleted */
|
||||||
|
audioDeleteAt: integer("audio_delete_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index("call_recordings_user_id_idx").on(table.userId),
|
||||||
|
phoneNumberIdx: index("call_recordings_phone_number_idx").on(table.phoneNumber),
|
||||||
|
analysisStatusIdx: index("call_recordings_analysis_status_idx").on(table.analysisStatus),
|
||||||
|
createdAtIdx: index("call_recordings_created_at_idx").on(table.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const callAnalysisSettings = sqliteTable("call_analysis_settings", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
/** Master toggle for call analysis */
|
||||||
|
callAnalysisEnabled: integer("call_analysis_enabled", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
/** Whether to auto-analyze after call ends */
|
||||||
|
autoAnalyze: integer("auto_analyze", { mode: "boolean" }).default(true).notNull(),
|
||||||
|
/** Audio retention in days (0 = delete immediately, default 0) */
|
||||||
|
audioRetentionDays: integer("audio_retention_days").default(0).notNull(),
|
||||||
|
/** Comma-separated list of two-party consent states where recording is disabled */
|
||||||
|
twoPartyConsentStates: text("two_party_consent_states").default("CA,CT,FL,HI,IL,MD,MA,MI,MT,NH,OR,PA,WA"),
|
||||||
|
/** Whether to send push notification on synthetic voice detection */
|
||||||
|
notifyOnSynthetic: integer("notify_on_synthetic", { mode: "boolean" }).default(true).notNull(),
|
||||||
|
/** Whether to auto-block numbers detected as synthetic */
|
||||||
|
autoBlockSynthetic: integer("auto_block_synthetic", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index("call_analysis_settings_user_id_idx").on(table.userId),
|
||||||
|
}));
|
||||||
|
|
||||||
export const analysisResults = sqliteTable("analysis_results", {
|
export const analysisResults = sqliteTable("analysis_results", {
|
||||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
analysisJobId: text("analysis_job_id").notNull().unique().references(() => analysisJobs.id),
|
analysisJobId: text("analysis_job_id").notNull().unique().references(() => analysisJobs.id),
|
||||||
|
|||||||
23
web/src/server/jobs/handlers/darkwatch.digest.ts
Normal file
23
web/src/server/jobs/handlers/darkwatch.digest.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { processDueDigests, cleanupOldDigests } from "~/server/services/darkwatch/digest.service";
|
||||||
|
import { cleanupExpiredCooldowns } from "~/server/services/darkwatch/alert.cooldown";
|
||||||
|
|
||||||
|
interface DarkWatchDigestPayload {
|
||||||
|
userId?: string; // If omitted, process all due digests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handler(payload: DarkWatchDigestPayload): Promise<void> {
|
||||||
|
console.log("[darkwatch.digest] Processing due digests...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process all due digest emails
|
||||||
|
await processDueDigests();
|
||||||
|
|
||||||
|
// Cleanup old data
|
||||||
|
await cleanupExpiredCooldowns();
|
||||||
|
await cleanupOldDigests();
|
||||||
|
|
||||||
|
console.log("[darkwatch.digest] Digest processing complete");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[darkwatch.digest] Digest processing failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,19 @@ describe("darkwatch.scan handler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("triggers scan when active watchlist items exist", async () => {
|
it("triggers scan when active watchlist items exist", async () => {
|
||||||
mockRunScan.mockResolvedValue({ scanId: "scan-1" });
|
mockRunScan.mockResolvedValue({ scanId: "scan-1", queued: false });
|
||||||
|
|
||||||
|
mockDb.select
|
||||||
|
.mockReturnValueOnce(makeChain([{ id: "sub-1", userId: "user-1", tier: "plus", status: "active" }]))
|
||||||
|
.mockReturnValueOnce(makeChain([{ id: "item-1", type: "email", value: "test@test.com" }]));
|
||||||
|
|
||||||
|
await handler({ userId: "user-1", subscriptionId: "sub-1" });
|
||||||
|
|
||||||
|
expect(mockRunScan).toHaveBeenCalledWith("user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles queued scan result", async () => {
|
||||||
|
mockRunScan.mockResolvedValue({ scanId: "scan-2", queued: true });
|
||||||
|
|
||||||
mockDb.select
|
mockDb.select
|
||||||
.mockReturnValueOnce(makeChain([{ id: "sub-1", userId: "user-1", tier: "plus", status: "active" }]))
|
.mockReturnValueOnce(makeChain([{ id: "sub-1", userId: "user-1", tier: "plus", status: "active" }]))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface DarkWatchScanPayload {
|
|||||||
export async function handler(payload: DarkWatchScanPayload): Promise<void> {
|
export async function handler(payload: DarkWatchScanPayload): Promise<void> {
|
||||||
const { userId, subscriptionId } = payload;
|
const { userId, subscriptionId } = payload;
|
||||||
|
|
||||||
|
// Verify subscription is active
|
||||||
const sub = await db
|
const sub = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptions)
|
.from(subscriptions)
|
||||||
@@ -23,6 +24,7 @@ export async function handler(payload: DarkWatchScanPayload): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify there are watchlist items to scan
|
||||||
const items = await db
|
const items = await db
|
||||||
.select()
|
.select()
|
||||||
.from(watchlistItems)
|
.from(watchlistItems)
|
||||||
@@ -33,6 +35,18 @@ export async function handler(payload: DarkWatchScanPayload): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runScan(userId);
|
// Run the scan — this handles:
|
||||||
console.log(`[darkwatch.scan] Completed scan for subscription ${subscriptionId}`);
|
// - Tier-based source selection
|
||||||
|
// - WebSocket progress events
|
||||||
|
// - Alert deduplication via cooldown
|
||||||
|
// - Scan history recording
|
||||||
|
// - Failure recovery (partial results)
|
||||||
|
// - Concurrent scan queueing
|
||||||
|
const result = await runScan(userId);
|
||||||
|
|
||||||
|
if (result.queued) {
|
||||||
|
console.log(`[darkwatch.scan] Scan queued for subscription ${subscriptionId} (scanId: ${result.scanId})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[darkwatch.scan] Scan started for subscription ${subscriptionId} (scanId: ${result.scanId})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function getHandlers(): HandlerMap {
|
|||||||
if (!handlers) {
|
if (!handlers) {
|
||||||
handlers = {
|
handlers = {
|
||||||
"darkwatch.scan": require("./darkwatch.scan").handler,
|
"darkwatch.scan": require("./darkwatch.scan").handler,
|
||||||
|
"darkwatch.digest": require("./darkwatch.digest").handler,
|
||||||
"voiceprint.batch": require("./voiceprint.batch").handler,
|
"voiceprint.batch": require("./voiceprint.batch").handler,
|
||||||
"hometitle.scan": require("./hometitle.scan").handler,
|
"hometitle.scan": require("./hometitle.scan").handler,
|
||||||
"removebrokers.process": require("./removebrokers.process").handler,
|
"removebrokers.process": require("./removebrokers.process").handler,
|
||||||
@@ -25,6 +26,7 @@ export function getHandlers(): HandlerMap {
|
|||||||
export function setHandlers(mock: Partial<HandlerMap>): void {
|
export function setHandlers(mock: Partial<HandlerMap>): void {
|
||||||
handlers = {
|
handlers = {
|
||||||
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
|
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
|
||||||
|
"darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}),
|
||||||
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
|
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
|
||||||
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
||||||
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { eq, and, inArray, or, isNull, lt } from "drizzle-orm";
|
import { eq, and, inArray, or, isNull, lt, desc } from "drizzle-orm";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { removalRequests, infoBrokers } from "~/server/db/schema";
|
import { removalRequests, infoBrokers, normalizedAlerts } from "~/server/db/schema";
|
||||||
import { processRemovals } from "~/server/services/removebrokers.service";
|
import { processRemovals, trackRemovalStatus as serviceTrackRemovalStatus } from "~/server/services/removebrokers.service";
|
||||||
|
import type { EngineConfig } from "~/server/services/removebrokers/removal.engine";
|
||||||
|
|
||||||
interface RemoveBrokersProcessPayload {
|
interface RemoveBrokersProcessPayload {
|
||||||
subscriptionId?: string;
|
subscriptionId?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job handler for processing removebrokers removal requests.
|
||||||
|
*
|
||||||
|
* Processes pending and failed removal requests using the Playwright-based
|
||||||
|
* removal engine. Handles retry with exponential backoff and CAPTCHA detection.
|
||||||
|
*/
|
||||||
export async function handler(payload: RemoveBrokersProcessPayload): Promise<void> {
|
export async function handler(payload: RemoveBrokersProcessPayload): Promise<void> {
|
||||||
const { subscriptionId, requestId } = payload;
|
const { subscriptionId, requestId } = payload;
|
||||||
|
|
||||||
|
// If a specific request is targeted, process only that one
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
const [request] = await db
|
const [request] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -22,8 +30,295 @@ export async function handler(payload: RemoveBrokersProcessPayload): Promise<voi
|
|||||||
console.warn(`[removebrokers.process] Request ${requestId} not found or not pending`);
|
console.warn(`[removebrokers.process] Request ${requestId} not found or not pending`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[removebrokers.process] Processing specific request ${requestId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await processRemovals();
|
// Engine configuration from environment
|
||||||
|
const engineConfig: EngineConfig = {
|
||||||
|
useProxy: process.env.PROXY_PROVIDER !== undefined,
|
||||||
|
headless: process.env.NODE_ENV === "production",
|
||||||
|
timeout: parseInt(process.env.REMOVEBROKERS_TIMEOUT ?? "30000", 10),
|
||||||
|
maxConcurrency: parseInt(process.env.REMOVEBROKERS_CONCURRENCY ?? "3", 10),
|
||||||
|
operationDelay: parseInt(process.env.REMOVEBROKERS_DELAY ?? "2000", 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processRemovals(engineConfig);
|
||||||
console.log(`[removebrokers.process] Processed ${result.processed} removal requests`);
|
console.log(`[removebrokers.process] Processed ${result.processed} removal requests`);
|
||||||
|
|
||||||
|
// Log individual results
|
||||||
|
for (const r of result.results) {
|
||||||
|
const statusLabel = r.status === "SUBMITTED" ? "✅" :
|
||||||
|
r.status === "RETRY" ? "🔄" :
|
||||||
|
r.status === "CAPTCHA_BLOCKED" ? "🔒" :
|
||||||
|
r.status === "FAILED" ? "❌" : "❓";
|
||||||
|
console.log(` ${statusLabel} ${r.id.slice(0, 8)}... → ${r.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[removebrokers.process] Error processing removals:`, err);
|
||||||
|
throw err; // Re-throw for job retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track status of submitted removal requests by re-scanning brokers.
|
||||||
|
* This is called periodically (e.g., every 24 hours) to check if listings
|
||||||
|
* have been removed.
|
||||||
|
*/
|
||||||
|
export async function trackStatusHandler(): Promise<void> {
|
||||||
|
// Find submitted requests that haven't been verified recently
|
||||||
|
const submitted = await db
|
||||||
|
.select()
|
||||||
|
.from(removalRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(removalRequests.status, ["SUBMITTED", "IN_PROGRESS"]),
|
||||||
|
or(
|
||||||
|
isNull(removalRequests.nextRetryAt),
|
||||||
|
lt(removalRequests.nextRetryAt, new Date()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
console.log(`[removebrokers.track] Checking ${submitted.length} submitted removals`);
|
||||||
|
|
||||||
|
for (const request of submitted) {
|
||||||
|
try {
|
||||||
|
const [broker] = await db
|
||||||
|
.select()
|
||||||
|
.from(infoBrokers)
|
||||||
|
.where(eq(infoBrokers.id, request.brokerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!broker) continue;
|
||||||
|
|
||||||
|
const personalInfo = request.personalInfo as unknown as import("~/server/services/removebrokers/removal.engine").PersonalInfo;
|
||||||
|
|
||||||
|
const brokerEntry: Parameters<typeof serviceTrackRemovalStatus>[0] = {
|
||||||
|
name: broker.name,
|
||||||
|
domain: broker.domain,
|
||||||
|
category: broker.category,
|
||||||
|
removalMethod: broker.removalMethod,
|
||||||
|
removalUrl: broker.removalUrl ?? "",
|
||||||
|
requiresAccount: broker.requiresAccount,
|
||||||
|
requiresVerification: broker.requiresVerification,
|
||||||
|
estimatedDays: broker.estimatedDays,
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await serviceTrackRemovalStatus(brokerEntry, personalInfo);
|
||||||
|
|
||||||
|
if (status.status === "completed") {
|
||||||
|
await import("~/server/services/removebrokers.service").then(({ updateRequestStatus }) =>
|
||||||
|
updateRequestStatus(request.id, "COMPLETED", {
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(` ✅ ${request.id.slice(0, 8)}... → COMPLETED`);
|
||||||
|
} else if (status.status === "failed") {
|
||||||
|
await import("~/server/services/removebrokers.service").then(({ updateRequestStatus }) =>
|
||||||
|
updateRequestStatus(request.id, "FAILED", {
|
||||||
|
error: status.detail ?? "Verification failed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(` ❌ ${request.id.slice(0, 8)}... → FAILED: ${status.detail}`);
|
||||||
|
} else {
|
||||||
|
// Still in progress — schedule next check
|
||||||
|
const nextCheck = new Date(Date.now() + broker.estimatedDays * 24 * 60 * 60 * 1000);
|
||||||
|
await import("~/server/services/removebrokers.service").then(({ updateRequestStatus }) =>
|
||||||
|
updateRequestStatus(request.id, "IN_PROGRESS", {
|
||||||
|
nextCheckAt: nextCheck.getTime(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ⚠️ Status check failed for ${request.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekly re-scan handler.
|
||||||
|
* Re-scans all completed removals to detect re-listings.
|
||||||
|
*/
|
||||||
|
export async function rescanHandler(): Promise<void> {
|
||||||
|
console.log("[removebrokers.rescan] Starting weekly re-scan...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runReScan } = await import("~/server/services/removebrokers/re-scan");
|
||||||
|
const result = await runReScan({
|
||||||
|
cooldownDays: 7,
|
||||||
|
batchSize: 50,
|
||||||
|
autoReSubmit: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[removebrokers.rescan] Complete: ${result.reScanned} scanned, ` +
|
||||||
|
`${result.stillRemoved} still removed, ${result.reListed} re-listed, ` +
|
||||||
|
`${result.autoSubmitted} auto-submitted, ${result.errors} errors`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If re-listings were detected, create an alert for engineering
|
||||||
|
if (result.reListed > 0) {
|
||||||
|
const reListedBrokers = result.details
|
||||||
|
.filter((d) => d.status === "re_listed")
|
||||||
|
.map((d) => d.brokerName)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
console.warn(`[removebrokers.rescan] Re-listings detected: ${reListedBrokers}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[removebrokers.rescan] Re-scan failed:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter health check handler.
|
||||||
|
* Runs daily to check for adapter breakage and auto-disable broken adapters.
|
||||||
|
*/
|
||||||
|
export async function adapterHealthHandler(): Promise<void> {
|
||||||
|
console.log("[removebrokers.health] Running adapter health check...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { checkSystemHealth, getBrokenAdapters } = await import("~/server/services/removebrokers/adapter-health");
|
||||||
|
|
||||||
|
const health = await checkSystemHealth();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[removebrokers.health] System health: ${health.healthy} healthy, ` +
|
||||||
|
`${health.degraded} degraded, ${health.broken} broken, ` +
|
||||||
|
`${health.disabled} disabled (${health.systemHealthPercentage}% healthy)`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (health.needsAlert) {
|
||||||
|
console.warn(`[removebrokers.health] ALERT: ${health.alertMessage}`);
|
||||||
|
|
||||||
|
// Store alert in database
|
||||||
|
try {
|
||||||
|
await db.insert(normalizedAlerts).values({
|
||||||
|
source: "REMOVEBROKERS",
|
||||||
|
category: "ADAPTER_HEALTH",
|
||||||
|
severity: "WARNING",
|
||||||
|
userId: "system",
|
||||||
|
title: "Adapter Health Alert",
|
||||||
|
description: health.alertMessage ?? "Multiple adapters are failing",
|
||||||
|
entities: {
|
||||||
|
healthy: health.healthy,
|
||||||
|
degraded: health.degraded,
|
||||||
|
broken: health.broken,
|
||||||
|
disabled: health.disabled,
|
||||||
|
threshold: 5,
|
||||||
|
},
|
||||||
|
sourceAlertId: `removebrokers:health:${Date.now()}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log broken adapters
|
||||||
|
const broken = await getBrokenAdapters();
|
||||||
|
for (const b of broken) {
|
||||||
|
console.warn(` ⚠️ Broken adapter: ${b.brokerName} (${b.failureCount} failures, last: ${b.lastError})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[removebrokers.health] Health check failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAPTCHA solver balance check handler.
|
||||||
|
* Runs daily to ensure the CAPTCHA solver has sufficient balance.
|
||||||
|
*/
|
||||||
|
export async function captchaBalanceHandler(): Promise<void> {
|
||||||
|
console.log("[removebrokers.captcha] Checking CAPTCHA solver balance...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { checkCaptchaSolverHealth } = await import("~/server/services/removebrokers/captcha-solver");
|
||||||
|
const health = await checkCaptchaSolverHealth();
|
||||||
|
|
||||||
|
if (!health.configured) {
|
||||||
|
console.warn("[removebrokers.captcha] CAPTCHA solver not configured (CAPTCHA_SOLVER_API_KEY missing)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!health.reachable) {
|
||||||
|
console.error(`[removebrokers.captcha] CAPTCHA solver unreachable: ${health.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[removebrokers.captcha] Balance: $${health.balance?.toFixed(2) ?? "unknown"}`);
|
||||||
|
|
||||||
|
if (health.balance !== undefined && health.balance < 5) {
|
||||||
|
console.warn(`[removebrokers.captcha] Low balance: $${health.balance.toFixed(2)} — refill soon`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(normalizedAlerts).values({
|
||||||
|
source: "REMOVEBROKERS",
|
||||||
|
category: "CAPTCHA_BALANCE",
|
||||||
|
severity: "WARNING",
|
||||||
|
userId: "system",
|
||||||
|
title: "CAPTCHA Solver Balance Low",
|
||||||
|
description: `CAPTCHA solver balance is $${health.balance.toFixed(2)}. Refill to ensure uninterrupted automated removals.`,
|
||||||
|
entities: { balance: health.balance },
|
||||||
|
sourceAlertId: `removebrokers:captcha:balance:${Date.now()}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[removebrokers.captcha] Balance check failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification processing handler.
|
||||||
|
* Checks for and processes opt-out confirmation emails.
|
||||||
|
*/
|
||||||
|
export async function emailVerificationHandler(): Promise<void> {
|
||||||
|
console.log("[removebrokers.email] Processing email confirmations...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { processConfirmations } = await import("~/server/services/removebrokers/email-verifier");
|
||||||
|
const result = await processConfirmations();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[removebrokers.email] Processed: ${result.confirmed} confirmed, ` +
|
||||||
|
`${result.expired} expired, ${result.failed} failed`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[removebrokers.email] Email verification processing failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cost check handler.
|
||||||
|
* Runs daily to ensure proxy + CAPTCHA costs per user are within budget ($4/user).
|
||||||
|
*/
|
||||||
|
export async function costCheckHandler(): Promise<void> {
|
||||||
|
console.log("[removebrokers.cost] Checking monthly costs...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { checkCostPerUser, getMonthlyCostSummary } = await import("~/server/services/removebrokers/cost-tracker");
|
||||||
|
|
||||||
|
const budget = await checkCostPerUser();
|
||||||
|
const monthly = await getMonthlyCostSummary();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[removebrokers.cost] Monthly: $${(monthly.totalCostCents / 100).toFixed(2)} total, ` +
|
||||||
|
`$${(monthly.proxyCostCents / 100).toFixed(2)} proxy, ` +
|
||||||
|
`$${(monthly.captchaCostCents / 100).toFixed(2)} captcha, ` +
|
||||||
|
`$${(budget.costPerUser / 100).toFixed(2)}/user`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!budget.withinBudget) {
|
||||||
|
console.warn(
|
||||||
|
`[removebrokers.cost] Over budget: $${(budget.costPerUser / 100).toFixed(2)}/user ` +
|
||||||
|
`(target: $4.00/user)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[removebrokers.cost] Cost check failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
export const JOB_TYPES = [
|
export const JOB_TYPES = [
|
||||||
"darkwatch.scan",
|
"darkwatch.scan",
|
||||||
|
"darkwatch.digest",
|
||||||
"voiceprint.batch",
|
"voiceprint.batch",
|
||||||
"hometitle.scan",
|
"hometitle.scan",
|
||||||
"removebrokers.process",
|
"removebrokers.process",
|
||||||
@@ -13,6 +14,7 @@ export type JobType = (typeof JOB_TYPES)[number];
|
|||||||
|
|
||||||
export type JobPayload = {
|
export type JobPayload = {
|
||||||
"darkwatch.scan": { userId: string; subscriptionId: string };
|
"darkwatch.scan": { userId: string; subscriptionId: string };
|
||||||
|
"darkwatch.digest": { userId: string };
|
||||||
"voiceprint.batch": { userId?: string; jobId?: string };
|
"voiceprint.batch": { userId?: string; jobId?: string };
|
||||||
"hometitle.scan": { userId: string; subscriptionId: string };
|
"hometitle.scan": { userId: string; subscriptionId: string };
|
||||||
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
||||||
|
|||||||
80
web/src/server/jobs/scheduler.test.ts
Normal file
80
web/src/server/jobs/scheduler.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("node-cron", () => ({
|
||||||
|
default: {
|
||||||
|
schedule: vi.fn().mockReturnValue({ stop: vi.fn() }),
|
||||||
|
validate: vi.fn().mockReturnValue(true),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeChain(result: any[]) {
|
||||||
|
const chain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
then: vi.fn().mockImplementation((fn: Function) => Promise.resolve(fn(result))),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("~/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn().mockReturnValue(makeChain([])),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/lib/tier", () => ({
|
||||||
|
getEffectiveTier: vi.fn((tier: string) => tier),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./queue", () => ({
|
||||||
|
getQueue: vi.fn().mockReturnValue({
|
||||||
|
enqueue: vi.fn().mockResolvedValue({ id: "job-1" }),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import cron from "node-cron";
|
||||||
|
import { getCronOverview, isSchedulerRunning, clearSchedules } from "./scheduler";
|
||||||
|
|
||||||
|
describe("scheduler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearSchedules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCronOverview", () => {
|
||||||
|
it("should return overview string with all tier schedules", () => {
|
||||||
|
const overview = getCronOverview();
|
||||||
|
expect(overview).toContain("Basic");
|
||||||
|
expect(overview).toContain("Plus");
|
||||||
|
expect(overview).toContain("Premium");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cron validation", () => {
|
||||||
|
it("should validate daily cron expression", () => {
|
||||||
|
expect(cron.validate("0 0 * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate weekly cron expression", () => {
|
||||||
|
expect(cron.validate("0 0 * * 0")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate monthly cron expression", () => {
|
||||||
|
expect(cron.validate("0 0 1 * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate digest cron expression", () => {
|
||||||
|
expect(cron.validate("0 9 * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSchedulerRunning", () => {
|
||||||
|
it("should return false initially", () => {
|
||||||
|
expect(isSchedulerRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,12 @@ const TIER_SCHEDULES: Record<string, Array<{ type: JobType; cron: string }>> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Global digest job — runs once daily for all users (not per-subscription)
|
||||||
|
const DIGEST_SCHEDULE: { type: JobType; cron: string } = {
|
||||||
|
type: "darkwatch.digest",
|
||||||
|
cron: "0 9 * * *", // 9 AM UTC daily
|
||||||
|
};
|
||||||
|
|
||||||
const CRON_OVERVIEW: Record<string, string> = {
|
const CRON_OVERVIEW: Record<string, string> = {
|
||||||
basic: "Basic: DarkWatch monthly (1st), HomeTitle monthly (2nd)",
|
basic: "Basic: DarkWatch monthly (1st), HomeTitle monthly (2nd)",
|
||||||
plus: "Plus: DarkWatch weekly (Sun), HomeTitle weekly (Sat), Reports monthly (1st)",
|
plus: "Plus: DarkWatch weekly (Sun), HomeTitle weekly (Sat), Reports monthly (1st)",
|
||||||
@@ -37,6 +43,7 @@ interface SchedulerEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let activeSchedules: SchedulerEntry[] = [];
|
let activeSchedules: SchedulerEntry[] = [];
|
||||||
|
let globalSchedules: cron.ScheduledTask[] = [];
|
||||||
let schedulerRunning = false;
|
let schedulerRunning = false;
|
||||||
|
|
||||||
export function getCronOverview(): string {
|
export function getCronOverview(): string {
|
||||||
@@ -49,6 +56,9 @@ async function enqueueScheduledJob(type: JobType, userId: string, subscriptionId
|
|||||||
case "darkwatch.scan":
|
case "darkwatch.scan":
|
||||||
await queue.enqueue(type, { userId, subscriptionId });
|
await queue.enqueue(type, { userId, subscriptionId });
|
||||||
break;
|
break;
|
||||||
|
case "darkwatch.digest":
|
||||||
|
await queue.enqueue(type, { userId });
|
||||||
|
break;
|
||||||
case "hometitle.scan":
|
case "hometitle.scan":
|
||||||
await queue.enqueue(type, { userId, subscriptionId });
|
await queue.enqueue(type, { userId, subscriptionId });
|
||||||
break;
|
break;
|
||||||
@@ -63,6 +73,9 @@ async function enqueueScheduledJob(type: JobType, userId: string, subscriptionId
|
|||||||
export async function registerSchedules(): Promise<void> {
|
export async function registerSchedules(): Promise<void> {
|
||||||
clearSchedules();
|
clearSchedules();
|
||||||
|
|
||||||
|
// Register global digest schedule (once, not per-subscription)
|
||||||
|
registerGlobalDigestSchedule();
|
||||||
|
|
||||||
const activeSubs = await db
|
const activeSubs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptions)
|
.from(subscriptions)
|
||||||
@@ -100,7 +113,24 @@ export async function registerSchedules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[scheduler] Registered ${activeSchedules.length} schedules for ${activeSubs.length} subscriptions`);
|
console.log(`[scheduler] Registered ${activeSchedules.length} schedules for ${activeSubs.length} subscriptions, ${globalSchedules.length} global schedule(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGlobalDigestSchedule(): void {
|
||||||
|
if (!cron.validate(DIGEST_SCHEDULE.cron)) {
|
||||||
|
console.warn(`[scheduler] Invalid digest cron expression: ${DIGEST_SCHEDULE.cron}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = cron.schedule(DIGEST_SCHEDULE.cron, () => {
|
||||||
|
const queue = getQueue();
|
||||||
|
queue.enqueue("darkwatch.digest", {}).catch((err) => {
|
||||||
|
console.error(`[scheduler] Failed to enqueue digest job:`, err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
globalSchedules.push(task);
|
||||||
|
console.log(`[scheduler] Registered global digest schedule: ${DIGEST_SCHEDULE.cron}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleForSubscription(
|
export function scheduleForSubscription(
|
||||||
@@ -148,6 +178,12 @@ export function clearSchedules(): void {
|
|||||||
entry.task.stop();
|
entry.task.stop();
|
||||||
}
|
}
|
||||||
activeSchedules = [];
|
activeSchedules = [];
|
||||||
|
|
||||||
|
for (const task of globalSchedules) {
|
||||||
|
task.stop();
|
||||||
|
}
|
||||||
|
globalSchedules = [];
|
||||||
|
|
||||||
console.log("[scheduler] All schedules cleared");
|
console.log("[scheduler] All schedules cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ const envSchema = object({
|
|||||||
TWILIO_MESSAGING_SERVICE_SID: optional(string()),
|
TWILIO_MESSAGING_SERVICE_SID: optional(string()),
|
||||||
|
|
||||||
// External APIs
|
// External APIs
|
||||||
|
ATTOM_API_KEY: optional(string()),
|
||||||
HIBP_API_KEY: optional(string()),
|
HIBP_API_KEY: optional(string()),
|
||||||
|
HIBP_RATE_PER_SECOND: optional(string()),
|
||||||
SECURITYTRAILS_API_KEY: optional(string()),
|
SECURITYTRAILS_API_KEY: optional(string()),
|
||||||
CENSYS_API_ID: optional(string()),
|
CENSYS_API_ID: optional(string()),
|
||||||
CENSYS_API_SECRET: optional(string()),
|
CENSYS_API_SECRET: optional(string()),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const rateLimitTiers: Record<string, RateLimitTier> = {
|
|||||||
admin: { limit: 50, windowMs: 60_000 },
|
admin: { limit: 50, windowMs: 60_000 },
|
||||||
websocket: { limit: 1, windowMs: 60_000 },
|
websocket: { limit: 1, windowMs: 60_000 },
|
||||||
websocketReconnect: { limit: 5, windowMs: 60_000 },
|
websocketReconnect: { limit: 5, windowMs: 60_000 },
|
||||||
|
reputation: { limit: 100, windowMs: 60_000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function checkRateLimit(
|
export async function checkRateLimit(
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import { db } from "~/server/db";
|
|||||||
import { featureTrials } from "~/server/db/schema/subscription";
|
import { featureTrials } from "~/server/db/schema/subscription";
|
||||||
import { and, eq, gte } from "drizzle-orm";
|
import { and, eq, gte } from "drizzle-orm";
|
||||||
|
|
||||||
export type Tier = "basic" | "plus" | "premium";
|
export type Tier = "basic" | "plus" | "premium" | "family_guard" | "family_fortress";
|
||||||
export type SubscriptionStatus = "active" | "past_due" | "canceled" | "unpaid" | "trialing";
|
export type SubscriptionStatus =
|
||||||
|
| "active"
|
||||||
|
| "past_due"
|
||||||
|
| "canceled"
|
||||||
|
| "unpaid"
|
||||||
|
| "trialing"
|
||||||
|
| "paused"
|
||||||
|
| "incomplete"
|
||||||
|
| "incomplete_expired";
|
||||||
|
|
||||||
export const TIER_ORDER: Record<Tier, number> = { basic: 0, plus: 1, premium: 2 };
|
export const TIER_ORDER: Record<Tier, number> = { basic: 0, plus: 1, premium: 2, family_guard: 3, family_fortress: 4 };
|
||||||
|
|
||||||
export const FEATURE_TIERS: Record<string, Tier> = {
|
export const FEATURE_TIERS: Record<string, Tier> = {
|
||||||
voiceprint: "plus",
|
voiceprint: "plus",
|
||||||
@@ -34,6 +42,7 @@ export interface SubWithEffectiveTier {
|
|||||||
|
|
||||||
export function getEffectiveTier(tier: Tier, status: SubscriptionStatus): Tier {
|
export function getEffectiveTier(tier: Tier, status: SubscriptionStatus): Tier {
|
||||||
if (status === "trialing") return "basic";
|
if (status === "trialing") return "basic";
|
||||||
|
if (status === "canceled" || status === "unpaid" || status === "incomplete_expired") return "basic";
|
||||||
return tier;
|
return tier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
web/src/server/models/spam-classifier/model.onnx
Normal file
BIN
web/src/server/models/spam-classifier/model.onnx
Normal file
Binary file not shown.
BIN
web/src/server/models/spam-classifier/model.onnx.data
Normal file
BIN
web/src/server/models/spam-classifier/model.onnx.data
Normal file
Binary file not shown.
17
web/src/server/models/spam-classifier/model_metadata.json
Normal file
17
web/src/server/models/spam-classifier/model_metadata.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"model_name": "distilbert-base-uncased",
|
||||||
|
"task": "sms-spam-classification",
|
||||||
|
"max_length": 128,
|
||||||
|
"num_labels": 2,
|
||||||
|
"label2id": {
|
||||||
|
"ham": 0,
|
||||||
|
"spam": 1
|
||||||
|
},
|
||||||
|
"id2label": {
|
||||||
|
"0": "ham",
|
||||||
|
"1": "spam"
|
||||||
|
},
|
||||||
|
"framework": "pytorch",
|
||||||
|
"export_format": "onnx"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
30686
web/src/server/models/spam-classifier/tokenizer.json
Normal file
30686
web/src/server/models/spam-classifier/tokenizer.json
Normal file
File diff suppressed because it is too large
Load Diff
56
web/src/server/models/spam-classifier/tokenizer_config.json
Normal file
56
web/src/server/models/spam-classifier/tokenizer_config.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"added_tokens_decoder": {
|
||||||
|
"0": {
|
||||||
|
"content": "[PAD]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"100": {
|
||||||
|
"content": "[UNK]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"101": {
|
||||||
|
"content": "[CLS]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"102": {
|
||||||
|
"content": "[SEP]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
},
|
||||||
|
"103": {
|
||||||
|
"content": "[MASK]",
|
||||||
|
"lstrip": false,
|
||||||
|
"normalized": false,
|
||||||
|
"rstrip": false,
|
||||||
|
"single_word": false,
|
||||||
|
"special": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clean_up_tokenization_spaces": false,
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"do_lower_case": true,
|
||||||
|
"extra_special_tokens": {},
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"model_max_length": 512,
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"strip_accents": null,
|
||||||
|
"tokenize_chinese_chars": true,
|
||||||
|
"tokenizer_class": "DistilBertTokenizer",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
30522
web/src/server/models/spam-classifier/vocab.txt
Normal file
30522
web/src/server/models/spam-classifier/vocab.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,17 @@ vi.mock("~/server/db", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/lib/resend", () => ({
|
||||||
|
resend: {
|
||||||
|
emails: { send: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./email.templates", () => ({
|
||||||
|
paymentFailedEmail: vi.fn(() => ({ subject: "Payment failed", html: "", text: "" })),
|
||||||
|
subscriptionActivatedEmail: vi.fn(() => ({ subject: "Activated", html: "", text: "" })),
|
||||||
|
}));
|
||||||
|
|
||||||
import { stripe } from "~/server/stripe";
|
import { stripe } from "~/server/stripe";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +45,9 @@ import {
|
|||||||
reactivateSubscription,
|
reactivateSubscription,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
handleWebhookEvent,
|
handleWebhookEvent,
|
||||||
|
createTrialSubscription,
|
||||||
|
changeSubscriptionTier,
|
||||||
|
mapStripeProductToTier,
|
||||||
} from "./billing.service";
|
} from "./billing.service";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -143,6 +157,124 @@ describe("createCheckoutSession", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates checkout session with trial period", async () => {
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ id: "u1", email: "a@b.com", stripeCustomerId: "cus_123" },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
stripe.checkout.sessions.create as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
id: "session_trial",
|
||||||
|
client_secret: "cs_trial_secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createCheckoutSession(
|
||||||
|
"u1",
|
||||||
|
"a@b.com",
|
||||||
|
"price_basic",
|
||||||
|
"https://example.com/return",
|
||||||
|
{ trial: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.clientSecret).toBe("cs_trial_secret");
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subscription_data: expect.objectContaining({
|
||||||
|
trial_period_days: 14,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates checkout session with proration for upgrades", async () => {
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ id: "u1", email: "a@b.com", stripeCustomerId: "cus_123" },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
stripe.checkout.sessions.create as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
id: "session_upgrade",
|
||||||
|
client_secret: "cs_upgrade_secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createCheckoutSession(
|
||||||
|
"u1",
|
||||||
|
"a@b.com",
|
||||||
|
"price_plus",
|
||||||
|
"https://example.com/return",
|
||||||
|
{ isUpgrade: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.clientSecret).toBe("cs_upgrade_secret");
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subscription_data: expect.objectContaining({
|
||||||
|
proration_behavior: "create_prorations",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTrialSubscription", () => {
|
||||||
|
it("creates a trial subscription with 14-day trial period", async () => {
|
||||||
|
process.env.STRIPE_PRICE_BASIC = "price_basic_trial";
|
||||||
|
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ id: "u1", email: "a@b.com", stripeCustomerId: "cus_123" },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
stripe.checkout.sessions.create as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
id: "session_trial_14",
|
||||||
|
url: "https://checkout.stripe.com/session_trial_14",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createTrialSubscription(
|
||||||
|
"u1",
|
||||||
|
"a@b.com",
|
||||||
|
"https://example.com/return",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe("session_trial_14");
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: "subscription",
|
||||||
|
subscription_data: {
|
||||||
|
trial_period_days: 14,
|
||||||
|
metadata: { userId: "u1" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createPortalSession", () => {
|
describe("createPortalSession", () => {
|
||||||
@@ -213,11 +345,61 @@ describe("listInvoices", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("changeSubscriptionTier", () => {
|
||||||
|
it("updates subscription tier with proration", async () => {
|
||||||
|
(stripe.subscriptions.retrieve as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "sub_123",
|
||||||
|
items: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "si_item123",
|
||||||
|
price: { id: "price_new" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(stripe.subscriptions.update as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "sub_123",
|
||||||
|
status: "active",
|
||||||
|
current_period_start: 1700000000,
|
||||||
|
current_period_end: 1702592000,
|
||||||
|
items: { data: [{ price: { id: "price_new" } }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ id: "sub_db_1", stripeId: "sub_123" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
returning: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: "sub_db_1", status: "active" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await changeSubscriptionTier("sub_123", "price_new");
|
||||||
|
expect(stripe.subscriptions.update).toHaveBeenCalledWith(
|
||||||
|
"sub_123",
|
||||||
|
expect.objectContaining({
|
||||||
|
proration_behavior: "create_prorations",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("handleWebhookEvent", () => {
|
describe("handleWebhookEvent", () => {
|
||||||
it("handles checkout.session.completed", async () => {
|
it("handles checkout.session.completed", async () => {
|
||||||
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
values: vi.fn().mockReturnValue({
|
values: vi.fn().mockReturnValue({
|
||||||
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
|
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,6 +414,14 @@ describe("handleWebhookEvent", () => {
|
|||||||
cancel_at_period_end: false,
|
cancel_at_period_end: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
await handleWebhookEvent({
|
await handleWebhookEvent({
|
||||||
type: "checkout.session.completed",
|
type: "checkout.session.completed",
|
||||||
data: {
|
data: {
|
||||||
@@ -246,22 +436,68 @@ describe("handleWebhookEvent", () => {
|
|||||||
expect(db.insert).toHaveBeenCalled();
|
expect(db.insert).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles invoice.paid", async () => {
|
it("handles invoice.payment_succeeded", async () => {
|
||||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([{ id: "sub_db_1" }]),
|
limit: vi.fn().mockResolvedValue([{ id: "sub_db_1", stripeId: "sub_123", userId: "u1" }]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
(
|
||||||
set: vi.fn().mockReturnValue({
|
stripe.subscriptions.retrieve as ReturnType<typeof vi.fn>
|
||||||
where: vi.fn().mockReturnValue({
|
).mockResolvedValue({
|
||||||
returning: vi
|
id: "sub_123",
|
||||||
.fn()
|
items: { data: [{ price: { id: "price_basic" } }] },
|
||||||
.mockResolvedValue([{ id: "sub_db_1", status: "active" }]),
|
current_period_start: 1700000000,
|
||||||
|
current_period_end: 1702592000,
|
||||||
|
status: "active",
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWebhookEvent({
|
||||||
|
type: "invoice.payment_succeeded",
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: "in_123",
|
||||||
|
subscription: "sub_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(stripe.subscriptions.retrieve).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invoice.paid (legacy event)", async () => {
|
||||||
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ id: "sub_db_1", stripeId: "sub_123", userId: "u1" }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
stripe.subscriptions.retrieve as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
id: "sub_123",
|
||||||
|
items: { data: [{ price: { id: "price_basic" } }] },
|
||||||
|
current_period_start: 1700000000,
|
||||||
|
current_period_end: 1702592000,
|
||||||
|
status: "active",
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,17 +505,22 @@ describe("handleWebhookEvent", () => {
|
|||||||
type: "invoice.paid",
|
type: "invoice.paid",
|
||||||
data: {
|
data: {
|
||||||
object: {
|
object: {
|
||||||
|
id: "in_123",
|
||||||
subscription: "sub_123",
|
subscription: "sub_123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
expect(stripe.subscriptions.retrieve).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles invoice.payment_failed", async () => {
|
it("handles invoice.payment_failed and sets past_due status", async () => {
|
||||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([{ id: "sub_db_1" }]),
|
limit: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "sub_db_1", stripeId: "sub_123", userId: "u1" },
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -298,31 +539,40 @@ describe("handleWebhookEvent", () => {
|
|||||||
type: "invoice.payment_failed",
|
type: "invoice.payment_failed",
|
||||||
data: {
|
data: {
|
||||||
object: {
|
object: {
|
||||||
|
id: "in_failed",
|
||||||
subscription: "sub_123",
|
subscription: "sub_123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
expect(db.update).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles customer.subscription.updated", async () => {
|
it("handles customer.subscription.updated", async () => {
|
||||||
(
|
|
||||||
db.query.subscriptions.findFirst as ReturnType<typeof vi.fn>
|
|
||||||
).mockResolvedValue(null);
|
|
||||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([]),
|
limit: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "sub_db_1", stripeId: "sub_123", userId: "u1" },
|
||||||
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
(
|
||||||
set: vi.fn().mockReturnValue({
|
stripe.subscriptions.retrieve as ReturnType<typeof vi.fn>
|
||||||
where: vi.fn().mockReturnValue({
|
).mockResolvedValue({
|
||||||
returning: vi
|
id: "sub_123",
|
||||||
.fn()
|
items: { data: [{ price: { id: "price_plus" } }] },
|
||||||
.mockResolvedValue([{ id: "sub_db_1", status: "active" }]),
|
current_period_start: 1700000000,
|
||||||
}),
|
current_period_end: 1702592000,
|
||||||
|
status: "active",
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,6 +590,8 @@ describe("handleWebhookEvent", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
expect(db.insert).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles customer.subscription.deleted", async () => {
|
it("handles customer.subscription.deleted", async () => {
|
||||||
@@ -369,5 +621,46 @@ describe("handleWebhookEvent", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
expect(db.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unknown event type gracefully", async () => {
|
||||||
|
await handleWebhookEvent({
|
||||||
|
type: "some.unknown.event",
|
||||||
|
data: { object: {} },
|
||||||
|
} as never);
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapStripeProductToTier", () => {
|
||||||
|
it("maps basic price to basic tier", () => {
|
||||||
|
process.env.STRIPE_PRICE_BASIC = "price_basic";
|
||||||
|
expect(mapStripeProductToTier("price_basic")).toBe("basic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps plus price to plus tier", () => {
|
||||||
|
process.env.STRIPE_PRICE_PLUS = "price_plus";
|
||||||
|
expect(mapStripeProductToTier("price_plus")).toBe("plus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps premium price to premium tier", () => {
|
||||||
|
process.env.STRIPE_PRICE_PREMIUM = "price_premium";
|
||||||
|
expect(mapStripeProductToTier("price_premium")).toBe("premium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to basic for unknown price", () => {
|
||||||
|
expect(mapStripeProductToTier("price_unknown")).toBe("basic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty price", () => {
|
||||||
|
expect(mapStripeProductToTier("")).toBe("basic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps by name pattern when env vars don't match", () => {
|
||||||
|
expect(mapStripeProductToTier("price_123_basic_456")).toBe("basic");
|
||||||
|
expect(mapStripeProductToTier("price_123_plus_456")).toBe("plus");
|
||||||
|
expect(mapStripeProductToTier("price_123_premium_456")).toBe("premium");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { safeParse } from "valibot";
|
import { safeParse } from "valibot";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { stripe } from "~/server/stripe";
|
import { stripe } from "~/server/stripe";
|
||||||
|
import { resend } from "~/server/lib/resend";
|
||||||
import { users } from "~/server/db/schema/auth";
|
import { users } from "~/server/db/schema/auth";
|
||||||
import { subscriptions } from "~/server/db/schema/subscription";
|
import { subscriptions } from "~/server/db/schema/subscription";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
@@ -11,8 +12,24 @@ import {
|
|||||||
SubscriptionSchema,
|
SubscriptionSchema,
|
||||||
InvoiceSchema,
|
InvoiceSchema,
|
||||||
} from "~/server/api/schemas/webhook";
|
} from "~/server/api/schemas/webhook";
|
||||||
|
import { paymentFailedEmail, subscriptionActivatedEmail } from "./email.templates";
|
||||||
|
|
||||||
type Tier = "basic" | "plus" | "premium";
|
export type Tier = "basic" | "plus" | "premium" | "family_guard" | "family_fortress";
|
||||||
|
export type SubscriptionStatus =
|
||||||
|
| "active"
|
||||||
|
| "past_due"
|
||||||
|
| "canceled"
|
||||||
|
| "unpaid"
|
||||||
|
| "trialing"
|
||||||
|
| "paused"
|
||||||
|
| "incomplete"
|
||||||
|
| "incomplete_expired";
|
||||||
|
|
||||||
|
const TRIAL_DAYS = 14;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Stripe customer lifecycle */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function getOrCreateCustomer(userId: string, email: string) {
|
export async function getOrCreateCustomer(userId: string, email: string) {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
@@ -42,14 +59,30 @@ export async function getOrCreateCustomer(userId: string, email: string) {
|
|||||||
return customer.id;
|
return customer.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Checkout sessions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function createCheckoutSession(
|
export async function createCheckoutSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
email: string,
|
email: string,
|
||||||
priceId: string,
|
priceId: string,
|
||||||
returnUrl: string,
|
returnUrl: string,
|
||||||
|
options: { trial?: boolean; isUpgrade?: boolean; isDowngrade?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const customerId = await getOrCreateCustomer(userId, email);
|
const customerId = await getOrCreateCustomer(userId, email);
|
||||||
|
|
||||||
|
const subscriptionData: Record<string, unknown> =
|
||||||
|
{
|
||||||
|
metadata: { userId },
|
||||||
|
trial_period_days: options.trial ? TRIAL_DAYS : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For upgrades / downgrades, set proration behavior
|
||||||
|
if (options.isUpgrade || options.isDowngrade) {
|
||||||
|
subscriptionData.proration_behavior = "create_prorations";
|
||||||
|
}
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
@@ -57,12 +90,20 @@ export async function createCheckoutSession(
|
|||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
return_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
return_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
metadata: { userId },
|
metadata: { userId },
|
||||||
|
subscription_data: subscriptionData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { clientSecret: session.client_secret ?? "", sessionId: session.id };
|
return { clientSecret: session.client_secret ?? "", sessionId: session.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPortalSession(customerId: string, returnUrl: string) {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Customer portal */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function createPortalSession(
|
||||||
|
customerId: string,
|
||||||
|
returnUrl: string,
|
||||||
|
) {
|
||||||
const session = await stripe.billingPortal.sessions.create({
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
return_url: returnUrl,
|
return_url: returnUrl,
|
||||||
@@ -71,6 +112,10 @@ export async function createPortalSession(customerId: string, returnUrl: string)
|
|||||||
return { url: session.url };
|
return { url: session.url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Subscription management */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function cancelSubscription(stripeSubscriptionId: string) {
|
export async function cancelSubscription(stripeSubscriptionId: string) {
|
||||||
await stripe.subscriptions.update(stripeSubscriptionId, {
|
await stripe.subscriptions.update(stripeSubscriptionId, {
|
||||||
cancel_at_period_end: true,
|
cancel_at_period_end: true,
|
||||||
@@ -97,6 +142,10 @@ export async function reactivateSubscription(stripeSubscriptionId: string) {
|
|||||||
return { cancelAtPeriodEnd: false };
|
return { cancelAtPeriodEnd: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Invoices */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function listInvoices(
|
export async function listInvoices(
|
||||||
customerId: string,
|
customerId: string,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
@@ -117,14 +166,106 @@ export async function listInvoices(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Trial creation */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function createTrialSubscription(
|
||||||
|
userId: string,
|
||||||
|
email: string,
|
||||||
|
returnUrl: string,
|
||||||
|
) {
|
||||||
|
const customerId = await getOrCreateCustomer(userId, email);
|
||||||
|
|
||||||
|
// Use the basic plan price for trial subscriptions
|
||||||
|
const trialPriceId = process.env.STRIPE_PRICE_BASIC;
|
||||||
|
if (!trialPriceId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Trial price ID not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: [{ price: trialPriceId, quantity: 1 }],
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
subscription_data: {
|
||||||
|
trial_period_days: TRIAL_DAYS,
|
||||||
|
metadata: { userId },
|
||||||
|
},
|
||||||
|
success_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${returnUrl}/pricing`,
|
||||||
|
metadata: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessionId: session.id, url: session.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tier change (upgrade / downgrade with proration) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function changeSubscriptionTier(
|
||||||
|
stripeSubscriptionId: string,
|
||||||
|
newPriceId: string,
|
||||||
|
) {
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
|
stripeSubscriptionId,
|
||||||
|
{ expand: ["items.data.price"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the subscription item with proration
|
||||||
|
const item = subscription.items.data[0];
|
||||||
|
if (!item) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No subscription items found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSub = await stripe.subscriptions.update(
|
||||||
|
stripeSubscriptionId,
|
||||||
|
{
|
||||||
|
items: [{ id: item.id, price: newPriceId }],
|
||||||
|
proration_behavior: "create_prorations",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update DB record
|
||||||
|
const tier = mapStripeProductToTier(newPriceId);
|
||||||
|
const subData = updatedSub as unknown as Record<string, unknown>;
|
||||||
|
await updateSubscriptionInDB(stripeSubscriptionId, {
|
||||||
|
tier,
|
||||||
|
stripePriceId: newPriceId,
|
||||||
|
status: (subData.status as SubscriptionStatus) ?? "active",
|
||||||
|
currentPeriodStart: subData.current_period_start
|
||||||
|
? new Date((subData.current_period_start as number) * 1000)
|
||||||
|
: undefined,
|
||||||
|
currentPeriodEnd: subData.current_period_end
|
||||||
|
? new Date((subData.current_period_end as number) * 1000)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { subscription: updatedSub };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Database helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export async function updateSubscriptionInDB(
|
export async function updateSubscriptionInDB(
|
||||||
stripeId: string,
|
stripeId: string,
|
||||||
data: {
|
data: {
|
||||||
tier?: Tier;
|
tier?: Tier;
|
||||||
status?: string;
|
stripePriceId?: string;
|
||||||
|
status?: SubscriptionStatus;
|
||||||
currentPeriodStart?: Date;
|
currentPeriodStart?: Date;
|
||||||
currentPeriodEnd?: Date;
|
currentPeriodEnd?: Date;
|
||||||
|
trialEnd?: Date;
|
||||||
cancelAtPeriodEnd?: boolean;
|
cancelAtPeriodEnd?: boolean;
|
||||||
|
defaultPaymentMethodLast4?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -134,9 +275,16 @@ export async function updateSubscriptionInDB(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
updateData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(subscriptions)
|
.update(subscriptions)
|
||||||
.set(data as Record<string, unknown>)
|
.set(updateData)
|
||||||
.where(eq(subscriptions.stripeId, stripeId))
|
.where(eq(subscriptions.stripeId, stripeId))
|
||||||
.returning();
|
.returning();
|
||||||
return updated;
|
return updated;
|
||||||
@@ -145,10 +293,16 @@ export async function updateSubscriptionInDB(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Valibot parsers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function safeParseSubscription(obj: unknown) {
|
function safeParseSubscription(obj: unknown) {
|
||||||
const result = safeParse(SubscriptionSchema, obj);
|
const result = safeParse(SubscriptionSchema, obj);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(`[webhook] Failed to parse subscription data: ${result.issues?.map((i) => i.message).join(", ")}`);
|
console.error(
|
||||||
|
`[billing:webhook] Failed to parse subscription data: ${result.issues?.map((i) => i.message).join(", ")}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return result.output;
|
return result.output;
|
||||||
@@ -157,7 +311,9 @@ function safeParseSubscription(obj: unknown) {
|
|||||||
function safeParseCheckoutSession(obj: unknown) {
|
function safeParseCheckoutSession(obj: unknown) {
|
||||||
const result = safeParse(CheckoutSessionSchema, obj);
|
const result = safeParse(CheckoutSessionSchema, obj);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(`[webhook] Failed to parse checkout session data: ${result.issues?.map((i) => i.message).join(", ")}`);
|
console.error(
|
||||||
|
`[billing:webhook] Failed to parse checkout session data: ${result.issues?.map((i) => i.message).join(", ")}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return result.output;
|
return result.output;
|
||||||
@@ -166,51 +322,194 @@ function safeParseCheckoutSession(obj: unknown) {
|
|||||||
function safeParseInvoice(obj: unknown) {
|
function safeParseInvoice(obj: unknown) {
|
||||||
const result = safeParse(InvoiceSchema, obj);
|
const result = safeParse(InvoiceSchema, obj);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(`[webhook] Failed to parse invoice data: ${result.issues?.map((i) => i.message).join(", ")}`);
|
console.error(
|
||||||
|
`[billing:webhook] Failed to parse invoice data: ${result.issues?.map((i) => i.message).join(", ")}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return result.output;
|
return result.output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleWebhookEvent(event: Stripe.Event) {
|
/* ------------------------------------------------------------------ */
|
||||||
switch (event.type) {
|
/* Webhook event handler */
|
||||||
case "checkout.session.completed": {
|
/* ------------------------------------------------------------------ */
|
||||||
const session = safeParseCheckoutSession(event.data.object);
|
|
||||||
if (!session) break;
|
|
||||||
|
|
||||||
const userId = session.metadata?.userId;
|
async function upsertSubscriptionFromStripe(
|
||||||
if (!userId || !session.subscription) break;
|
userId: string,
|
||||||
|
stripeSub: Stripe.Subscription,
|
||||||
const stripeSub = await stripe.subscriptions.retrieve(session.subscription);
|
) {
|
||||||
|
|
||||||
// Fetch fresh subscription data from Stripe for accurate fields
|
|
||||||
const subData = stripeSub as unknown as Record<string, unknown>;
|
const subData = stripeSub as unknown as Record<string, unknown>;
|
||||||
|
const priceItem = stripeSub.items.data[0]?.price;
|
||||||
|
const priceId =
|
||||||
|
typeof priceItem === "string"
|
||||||
|
? priceItem
|
||||||
|
: (priceItem as Stripe.Price | undefined)?.id ?? "";
|
||||||
|
|
||||||
await db.insert(subscriptions).values({
|
const insertData = {
|
||||||
userId,
|
userId,
|
||||||
stripeId: stripeSub.id,
|
stripeId: stripeSub.id,
|
||||||
tier: mapStripeProductToTier(
|
stripePriceId: priceId || undefined,
|
||||||
stripeSub.items.data[0]?.price?.id ?? "",
|
tier: mapStripeProductToTier(priceId),
|
||||||
),
|
status: (subData.status as SubscriptionStatus) ?? "active",
|
||||||
status: (subData.status as typeof subscriptions.$inferSelect.status) ?? "active",
|
|
||||||
currentPeriodStart: subData.current_period_start
|
currentPeriodStart: subData.current_period_start
|
||||||
? new Date((subData.current_period_start as number) * 1000)
|
? new Date((subData.current_period_start as number) * 1000)
|
||||||
: undefined,
|
: undefined,
|
||||||
currentPeriodEnd: subData.current_period_end
|
currentPeriodEnd: subData.current_period_end
|
||||||
? new Date((subData.current_period_end as number) * 1000)
|
? new Date((subData.current_period_end as number) * 1000)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
trialEnd: subData.trial_end
|
||||||
|
? new Date((subData.trial_end as number) * 1000)
|
||||||
|
: undefined,
|
||||||
cancelAtPeriodEnd: Boolean(subData.cancel_at_period_end),
|
cancelAtPeriodEnd: Boolean(subData.cancel_at_period_end),
|
||||||
}).onConflictDoNothing();
|
};
|
||||||
|
|
||||||
|
// Upsert: insert or update if stripeId already exists
|
||||||
|
await db
|
||||||
|
.insert(subscriptions)
|
||||||
|
.values(insertData)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: subscriptions.stripeId,
|
||||||
|
set: {
|
||||||
|
tier: insertData.tier,
|
||||||
|
status: insertData.status,
|
||||||
|
currentPeriodStart: insertData.currentPeriodStart,
|
||||||
|
currentPeriodEnd: insertData.currentPeriodEnd,
|
||||||
|
trialEnd: insertData.trialEnd,
|
||||||
|
cancelAtPeriodEnd: insertData.cancelAtPeriodEnd,
|
||||||
|
stripePriceId: insertData.stripePriceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractPaymentMethodLast4(
|
||||||
|
stripeSub: Stripe.Subscription,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const defaultSource = stripeSub.default_payment_method;
|
||||||
|
if (!defaultSource || typeof defaultSource === "string") return undefined;
|
||||||
|
const pm = defaultSource as Stripe.PaymentMethod;
|
||||||
|
if (pm.card?.last4) return pm.card.last4;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebhookEvent(event: Stripe.Event) {
|
||||||
|
const eventType = event.type;
|
||||||
|
console.log(`[billing:webhook] Processing event: ${eventType} (${event.id})`);
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const session = safeParseCheckoutSession(event.data.object);
|
||||||
|
if (!session) break;
|
||||||
|
|
||||||
|
const userId = session.metadata?.userId;
|
||||||
|
if (!userId || !session.subscription) {
|
||||||
|
console.warn(
|
||||||
|
`[billing:webhook] checkout.session.completed missing userId or subscription`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stripeSub = await stripe.subscriptions.retrieve(
|
||||||
|
session.subscription as string,
|
||||||
|
{ expand: ["default_payment_method"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await upsertSubscriptionFromStripe(userId, stripeSub);
|
||||||
|
|
||||||
|
// Update payment method last4
|
||||||
|
const last4 = await extractPaymentMethodLast4(stripeSub);
|
||||||
|
if (last4) {
|
||||||
|
await updateSubscriptionInDB(stripeSub.id, {
|
||||||
|
defaultPaymentMethodLast4: last4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a trial subscription, send activation email
|
||||||
|
if (stripeSub.status === "trialing") {
|
||||||
|
try {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (user?.email) {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: "Kordant <noreply@kordant.com>",
|
||||||
|
to: user.email,
|
||||||
|
...subscriptionActivatedEmail(
|
||||||
|
user.name ?? "there",
|
||||||
|
"Basic",
|
||||||
|
TRIAL_DAYS,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.error(
|
||||||
|
`[billing:webhook] Failed to send trial activation email:`,
|
||||||
|
emailErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_succeeded":
|
||||||
case "invoice.paid": {
|
case "invoice.paid": {
|
||||||
const invoice = safeParseInvoice(event.data.object);
|
const invoice = safeParseInvoice(event.data.object);
|
||||||
if (!invoice?.subscription) break;
|
if (!invoice?.subscription) break;
|
||||||
|
|
||||||
await updateSubscriptionInDB(invoice.subscription, {
|
const stripeSub = await stripe.subscriptions.retrieve(
|
||||||
status: "active",
|
invoice.subscription as string,
|
||||||
|
{ expand: ["default_payment_method"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the user from the subscription record
|
||||||
|
const [existingSub] = await db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(eq(subscriptions.stripeId, invoice.subscription as string))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSub) {
|
||||||
|
await upsertSubscriptionFromStripe(existingSub.userId, stripeSub);
|
||||||
|
|
||||||
|
const last4 = await extractPaymentMethodLast4(stripeSub);
|
||||||
|
if (last4) {
|
||||||
|
await updateSubscriptionInDB(stripeSub.id, {
|
||||||
|
defaultPaymentMethodLast4: last4,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this was a trial-to-paid transition, send activation email
|
||||||
|
if (stripeSub.trial_end && stripeSub.status === "active") {
|
||||||
|
try {
|
||||||
|
const userId = existingSub?.userId;
|
||||||
|
if (userId) {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (user?.email) {
|
||||||
|
const tier = mapStripeProductToTier(
|
||||||
|
(stripeSub.items.data[0]?.price as Stripe.Price)?.id ?? "",
|
||||||
|
);
|
||||||
|
await resend.emails.send({
|
||||||
|
from: "Kordant <noreply@kordant.com>",
|
||||||
|
to: user.email,
|
||||||
|
...subscriptionActivatedEmail(user.name ?? "there", tier, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.error(
|
||||||
|
`[billing:webhook] Failed to send subscription activation email:`,
|
||||||
|
emailErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,43 +517,86 @@ export async function handleWebhookEvent(event: Stripe.Event) {
|
|||||||
const invoice = safeParseInvoice(event.data.object);
|
const invoice = safeParseInvoice(event.data.object);
|
||||||
if (!invoice?.subscription) break;
|
if (!invoice?.subscription) break;
|
||||||
|
|
||||||
await updateSubscriptionInDB(invoice.subscription, {
|
await updateSubscriptionInDB(invoice.subscription as string, {
|
||||||
status: "past_due",
|
status: "past_due",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send payment failure / retry email
|
||||||
|
try {
|
||||||
|
const [existingSub] = await db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(eq(subscriptions.stripeId, invoice.subscription as string))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSub) {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, existingSub.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (user?.email) {
|
||||||
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: user.stripeCustomerId!,
|
||||||
|
return_url: `${process.env.APP_URL ?? "https://kordant.com"}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: "Kordant <noreply@kordant.com>",
|
||||||
|
to: user.email,
|
||||||
|
...paymentFailedEmail(user.name ?? "there", portalSession.url),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.error(
|
||||||
|
`[billing:webhook] Failed to send payment failure email:`,
|
||||||
|
emailErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "customer.subscription.updated": {
|
case "customer.subscription.updated": {
|
||||||
const validatedSub = safeParseSubscription(event.data.object);
|
const validatedSub = safeParseSubscription(event.data.object);
|
||||||
|
|
||||||
if (!validatedSub) break;
|
if (!validatedSub) break;
|
||||||
|
|
||||||
const userId = validatedSub.metadata?.userId;
|
// Find existing subscription to get userId
|
||||||
if (!userId) {
|
|
||||||
const [existingSub] = await db
|
const [existingSub] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptions)
|
.from(subscriptions)
|
||||||
.where(eq(subscriptions.stripeId, validatedSub.id))
|
.where(eq(subscriptions.stripeId, validatedSub.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existingSub) break;
|
if (!existingSub) {
|
||||||
|
// Subscription doesn't exist in DB yet — might be from metadata
|
||||||
|
const userId = validatedSub.metadata?.userId;
|
||||||
|
if (!userId) break;
|
||||||
|
|
||||||
|
const stripeSub = await stripe.subscriptions.retrieve(
|
||||||
|
validatedSub.id,
|
||||||
|
{ expand: ["default_payment_method"] },
|
||||||
|
);
|
||||||
|
await upsertSubscriptionFromStripe(userId, stripeSub);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = validatedSub.items?.data?.price?.id
|
// Retrieve full subscription from Stripe for accurate data
|
||||||
? mapStripeProductToTier(validatedSub.items.data.price.id)
|
const stripeSub = await stripe.subscriptions.retrieve(validatedSub.id, {
|
||||||
: undefined;
|
expand: ["default_payment_method"],
|
||||||
|
|
||||||
await updateSubscriptionInDB(validatedSub.id, {
|
|
||||||
tier,
|
|
||||||
status: validatedSub.status ?? undefined,
|
|
||||||
currentPeriodStart: validatedSub.current_period_start
|
|
||||||
? new Date(validatedSub.current_period_start * 1000)
|
|
||||||
: undefined,
|
|
||||||
currentPeriodEnd: validatedSub.current_period_end
|
|
||||||
? new Date(validatedSub.current_period_end * 1000)
|
|
||||||
: undefined,
|
|
||||||
cancelAtPeriodEnd: validatedSub.cancel_at_period_end ?? undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await upsertSubscriptionFromStripe(existingSub.userId, stripeSub);
|
||||||
|
|
||||||
|
const last4 = await extractPaymentMethodLast4(stripeSub);
|
||||||
|
if (last4) {
|
||||||
|
await updateSubscriptionInDB(stripeSub.id, {
|
||||||
|
defaultPaymentMethodLast4: last4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,14 +607,44 @@ export async function handleWebhookEvent(event: Stripe.Event) {
|
|||||||
await updateSubscriptionInDB(stripeSub.id, {
|
await updateSubscriptionInDB(stripeSub.id, {
|
||||||
status: "canceled",
|
status: "canceled",
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
console.log(
|
||||||
|
`[billing:webhook] Unhandled event type: ${eventType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tier mapping */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function mapStripeProductToTier(priceId: string): Tier {
|
export function mapStripeProductToTier(priceId: string): Tier {
|
||||||
if (priceId === process.env.STRIPE_PRICE_BASIC) return "basic";
|
if (!priceId) return "basic";
|
||||||
if (priceId === process.env.STRIPE_PRICE_PLUS) return "plus";
|
|
||||||
if (priceId === process.env.STRIPE_PRICE_PREMIUM) return "premium";
|
const envBasic = process.env.STRIPE_PRICE_BASIC ?? "";
|
||||||
|
const envPlus = process.env.STRIPE_PRICE_PLUS ?? "";
|
||||||
|
const envPremium = process.env.STRIPE_PRICE_PREMIUM ?? "";
|
||||||
|
const envFamilyGuard = process.env.STRIPE_PRICE_FAMILY_GUARD ?? "";
|
||||||
|
const envFamilyFortress = process.env.STRIPE_PRICE_FAMILY_FORTRESS ?? "";
|
||||||
|
|
||||||
|
if (priceId === envBasic) return "basic";
|
||||||
|
if (priceId === envPlus) return "plus";
|
||||||
|
if (priceId === envPremium) return "premium";
|
||||||
|
if (priceId === envFamilyGuard) return "family_guard";
|
||||||
|
if (priceId === envFamilyFortress) return "family_fortress";
|
||||||
|
|
||||||
|
// Also check for product ID prefixes or metadata patterns
|
||||||
|
// Check family plans FIRST to avoid mis-matching "family_guard" as "plus"
|
||||||
|
if (priceId.includes("family_fortress")) return "family_fortress";
|
||||||
|
if (priceId.includes("family_guard")) return "family_guard";
|
||||||
|
if (priceId.includes("basic") || priceId.includes("shield")) return "basic";
|
||||||
|
if (priceId.includes("plus") || priceId.includes("guard")) return "plus";
|
||||||
|
if (priceId.includes("premium") || priceId.includes("fortress")) return "premium";
|
||||||
|
|
||||||
return "basic";
|
return "basic";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,28 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
const mockInsert = vi.fn();
|
const mockInsert = vi.fn();
|
||||||
const mockUpdate = vi.fn();
|
const mockUpdate = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
vi.mock("~/server/db", () => ({
|
vi.mock("~/server/db", () => ({
|
||||||
db: {
|
db: {
|
||||||
select: mockSelect,
|
select: mockSelect,
|
||||||
insert: mockInsert,
|
insert: mockInsert,
|
||||||
update: mockUpdate,
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/server/db/schema", () => ({
|
vi.mock("~/server/db/schema", () => ({
|
||||||
normalizedAlerts: {},
|
normalizedAlerts: {},
|
||||||
correlationGroups: {},
|
correlationGroups: {},
|
||||||
|
threatScoreSnapshots: {},
|
||||||
auditLogs: {},
|
auditLogs: {},
|
||||||
|
familyGroupMembers: {},
|
||||||
|
familyGroups: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/db/schema/family", () => ({
|
||||||
|
familyGroupMembers: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -39,7 +48,8 @@ describe("getThreatScore", () => {
|
|||||||
const { getThreatScore } = await import("./correlation.service");
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
const result = await getThreatScore("user-1");
|
const result = await getThreatScore("user-1");
|
||||||
expect(result.score).toBe(0);
|
expect(result.score).toBe(0);
|
||||||
expect(result.breakdown).toEqual([]);
|
expect(result.baseScore).toBe(0);
|
||||||
|
expect(result.correlationBonus).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns higher score for more severe alerts", async () => {
|
it("returns higher score for more severe alerts", async () => {
|
||||||
@@ -47,6 +57,10 @@ describe("getThreatScore", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
severity: "CRITICAL",
|
severity: "CRITICAL",
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
createdAt: daysAgo(1),
|
createdAt: daysAgo(1),
|
||||||
};
|
};
|
||||||
makeSelectChain([highAlert]);
|
makeSelectChain([highAlert]);
|
||||||
@@ -54,7 +68,7 @@ describe("getThreatScore", () => {
|
|||||||
const { getThreatScore } = await import("./correlation.service");
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
const result = await getThreatScore("user-1");
|
const result = await getThreatScore("user-1");
|
||||||
expect(result.score).toBeGreaterThan(0);
|
expect(result.score).toBeGreaterThan(0);
|
||||||
expect(result.breakdown[0].source).toBe("DARKWATCH");
|
expect(result.sourceBreakdown["DARKWATCH"]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns lower score for less severe alerts", async () => {
|
it("returns lower score for less severe alerts", async () => {
|
||||||
@@ -62,6 +76,10 @@ describe("getThreatScore", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
severity: "LOW",
|
severity: "LOW",
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
createdAt: daysAgo(1),
|
createdAt: daysAgo(1),
|
||||||
};
|
};
|
||||||
makeSelectChain([lowAlert]);
|
makeSelectChain([lowAlert]);
|
||||||
@@ -78,6 +96,10 @@ describe("getThreatScore", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
severity: "CRITICAL",
|
severity: "CRITICAL",
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
createdAt: daysAgo(1),
|
createdAt: daysAgo(1),
|
||||||
}]);
|
}]);
|
||||||
const highResult = await getScore("user-1");
|
const highResult = await getScore("user-1");
|
||||||
@@ -86,6 +108,10 @@ describe("getThreatScore", () => {
|
|||||||
id: "a2",
|
id: "a2",
|
||||||
severity: "LOW",
|
severity: "LOW",
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
createdAt: daysAgo(1),
|
createdAt: daysAgo(1),
|
||||||
}]);
|
}]);
|
||||||
const lowResult = await getScore("user-1");
|
const lowResult = await getScore("user-1");
|
||||||
@@ -101,16 +127,50 @@ describe("getThreatScore", () => {
|
|||||||
expect(result.score).toBe(0);
|
expect(result.score).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides breakdown by source", async () => {
|
it("provides source breakdown", async () => {
|
||||||
const alerts = [
|
const alerts = [
|
||||||
{ id: "a1", severity: "HIGH", source: "DARKWATCH", createdAt: daysAgo(1) },
|
{
|
||||||
{ id: "a2", severity: "WARNING", source: "SPAMSHIELD", createdAt: daysAgo(1) },
|
id: "a1", severity: "HIGH", source: "DARKWATCH", category: "BREACH_EXPOSURE",
|
||||||
|
title: "Test", description: "Test",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2", severity: "WARNING", source: "SPAMSHIELD", category: "SPAM_CALL",
|
||||||
|
title: "Test", description: "Test",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
makeSelectChain(alerts);
|
makeSelectChain(alerts);
|
||||||
|
|
||||||
const { getThreatScore } = await import("./correlation.service");
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
const result = await getThreatScore("user-1");
|
const result = await getThreatScore("user-1");
|
||||||
expect(result.breakdown.length).toBeGreaterThanOrEqual(2);
|
expect(result.sourceBreakdown["DARKWATCH"]).toBeDefined();
|
||||||
|
expect(result.sourceBreakdown["SPAMSHIELD"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes correlation bonus when rules match", async () => {
|
||||||
|
const alerts = [
|
||||||
|
{
|
||||||
|
id: "a1", severity: "HIGH", source: "DARKWATCH", category: "BREACH_EXPOSURE",
|
||||||
|
title: "Test", description: "Test",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2", severity: "WARNING", source: "SPAMSHIELD", category: "SPAM_CALL",
|
||||||
|
title: "Test", description: "Test",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
makeSelectChain(alerts);
|
||||||
|
|
||||||
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScore("user-1");
|
||||||
|
expect(result.correlationBonus).toBeGreaterThanOrEqual(30); // RULE_1 bonus
|
||||||
|
expect(result.correlationCount).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +204,7 @@ describe("resolveAlert", () => {
|
|||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
entities: { emails: [], phones: [], ssns: [] },
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
title: "Test",
|
title: "Test",
|
||||||
|
groupId: null,
|
||||||
}]);
|
}]);
|
||||||
const selectWhere = vi.fn().mockReturnValue({ limit: selectLimit });
|
const selectWhere = vi.fn().mockReturnValue({ limit: selectLimit });
|
||||||
const selectFrom = vi.fn().mockReturnValue({ where: selectWhere });
|
const selectFrom = vi.fn().mockReturnValue({ where: selectWhere });
|
||||||
@@ -174,12 +235,75 @@ describe("resolveAlert", () => {
|
|||||||
const updateGroupSet = vi.fn().mockReturnValue({ where: updateGroupWhere });
|
const updateGroupSet = vi.fn().mockReturnValue({ where: updateGroupWhere });
|
||||||
mockUpdate.mockReturnValueOnce({ set: updateGroupSet });
|
mockUpdate.mockReturnValueOnce({ set: updateGroupSet });
|
||||||
|
|
||||||
const auditReturning = vi.fn().mockResolvedValue([{}]);
|
|
||||||
const auditValues = vi.fn().mockReturnValue({ returning: auditReturning });
|
|
||||||
mockInsert.mockReturnValue({ values: auditValues });
|
|
||||||
|
|
||||||
const { resolveAlert } = await import("./correlation.service");
|
const { resolveAlert } = await import("./correlation.service");
|
||||||
const result = await resolveAlert("user-1", "a1", "RESOLVED");
|
const result = await resolveAlert("user-1", "a1", "RESOLVED");
|
||||||
expect(result.status).toBe("RESOLVED");
|
expect(result.status).toBe("RESOLVED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getAlertStats", () => {
|
||||||
|
it("returns stats with threat score and correlation data", async () => {
|
||||||
|
// Mock all the queries in order
|
||||||
|
const whereFn = vi.fn().mockResolvedValue([{ count: 0 }]);
|
||||||
|
const fromFn = vi.fn().mockReturnValue({ where: whereFn });
|
||||||
|
|
||||||
|
// Total alerts count
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
// By severity
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
// By source
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
// Active groups
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
// Resolved groups
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
// FP groups
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
// Threat score alerts
|
||||||
|
mockSelect.mockReturnValueOnce({ from: fromFn });
|
||||||
|
|
||||||
|
const { getAlertStats } = await import("./correlation.service");
|
||||||
|
const result = await getAlertStats("user-1");
|
||||||
|
expect(result.totalAlerts).toBe(0);
|
||||||
|
expect(result.threatScore).toBe(0);
|
||||||
|
expect(result.correlationBonus).toBe(0);
|
||||||
|
expect(result.narratives).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getThreatScoreTrend", () => {
|
||||||
|
it("returns trend data with data points", async () => {
|
||||||
|
// Threat score (no alerts)
|
||||||
|
const noAlertsWhere = vi.fn().mockResolvedValue([]);
|
||||||
|
const noAlertsFrom = vi.fn().mockReturnValue({ where: noAlertsWhere });
|
||||||
|
mockSelect.mockReturnValueOnce({ from: noAlertsFrom });
|
||||||
|
|
||||||
|
// Snapshots
|
||||||
|
const snapshotsWhere = vi.fn().mockResolvedValue([]);
|
||||||
|
const snapshotsOrderBy = vi.fn().mockReturnValue({ where: snapshotsWhere });
|
||||||
|
const snapshotsFrom = vi.fn().mockReturnValue({ orderBy: snapshotsOrderBy });
|
||||||
|
mockSelect.mockReturnValueOnce({ from: snapshotsFrom });
|
||||||
|
|
||||||
|
const { getThreatScoreTrend } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScoreTrend("user-1");
|
||||||
|
expect(result.currentScore).toBe(0);
|
||||||
|
expect(result.dataPoints).toEqual([]);
|
||||||
|
expect(result.threatLevel.level).toBe("low");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRecommendations", () => {
|
||||||
|
it("returns recommendations based on threat score", async () => {
|
||||||
|
// No alerts
|
||||||
|
const noAlertsWhere = vi.fn().mockResolvedValue([]);
|
||||||
|
const noAlertsFrom = vi.fn().mockReturnValue({ where: noAlertsWhere });
|
||||||
|
mockSelect.mockReturnValueOnce({ from: noAlertsFrom });
|
||||||
|
|
||||||
|
const { getRecommendations } = await import("./correlation.service");
|
||||||
|
const result = await getRecommendations("user-1");
|
||||||
|
expect(result.score).toBe(0);
|
||||||
|
expect(result.threatLevel.level).toBe("low");
|
||||||
|
// Low score should have minimal recommendations
|
||||||
|
expect(result.recommendations.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, count, gte, inArray, sql, lte } from "drizzle-orm";
|
import { and, desc, eq, count, gte, inArray, sql, lte, asc, avg } from "drizzle-orm";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { normalizedAlerts, correlationGroups, auditLogs } from "~/server/db/schema";
|
import { normalizedAlerts, correlationGroups, threatScoreSnapshots } from "~/server/db/schema";
|
||||||
import {
|
import {
|
||||||
findRelatedAlerts,
|
findRelatedAlerts,
|
||||||
createCorrelationGroup,
|
createCorrelationGroup,
|
||||||
@@ -9,46 +9,51 @@ import {
|
|||||||
deduplicateAlerts,
|
deduplicateAlerts,
|
||||||
} from "./correlation/engine";
|
} from "./correlation/engine";
|
||||||
import type { NormalizedAlertInput, EntitySet } from "./correlation/normalizer";
|
import type { NormalizedAlertInput, EntitySet } from "./correlation/normalizer";
|
||||||
|
import type { AlertContext } from "./correlation/rules";
|
||||||
|
import { runCorrelationRules, ALL_RULES } from "./correlation/rules";
|
||||||
|
import {
|
||||||
|
calculateThreatScore,
|
||||||
|
calculateFamilyThreatScore,
|
||||||
|
generateRecommendations,
|
||||||
|
getThreatLevel,
|
||||||
|
type ThreatScoreResult,
|
||||||
|
} from "./correlation/scoring";
|
||||||
|
import { familyGroupMembers } from "~/server/db/schema/family";
|
||||||
|
import { eq as familyEq, and as familyAnd } from "drizzle-orm";
|
||||||
|
|
||||||
const SEVERITY_WEIGHTS: Record<string, number> = {
|
/**
|
||||||
CRITICAL: 40,
|
* Fetch all alerts for a user within the correlation window (30 days).
|
||||||
HIGH: 25,
|
*/
|
||||||
WARNING: 15,
|
async function fetchUserAlerts(userId: string): Promise<AlertContext[]> {
|
||||||
MEDIUM: 10,
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
INFO: 5,
|
|
||||||
LOW: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function ensureGroupForAlert(alertId: string, userId: string): Promise<string> {
|
const alerts = await db
|
||||||
const [alert] = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(normalizedAlerts)
|
.from(normalizedAlerts)
|
||||||
.where(eq(normalizedAlerts.id, alertId))
|
.where(
|
||||||
.limit(1);
|
and(
|
||||||
|
eq(normalizedAlerts.userId, userId),
|
||||||
|
gte(normalizedAlerts.createdAt, thirtyDaysAgo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (!alert) throw new TRPCError({ code: "NOT_FOUND", message: "Alert not found" });
|
return alerts.map(a => ({
|
||||||
|
id: a.id,
|
||||||
if (alert.groupId) return alert.groupId;
|
source: a.source,
|
||||||
|
category: a.category,
|
||||||
const [group] = await db
|
severity: a.severity,
|
||||||
.insert(correlationGroups)
|
title: a.title,
|
||||||
.values({
|
description: a.description,
|
||||||
userId,
|
entities: a.entities as unknown as EntitySet,
|
||||||
entities: alert.entities as Record<string, unknown>,
|
payload: a.payload as Record<string, unknown> | undefined,
|
||||||
highestSeverity: alert.severity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
createdAt: a.createdAt,
|
||||||
alertCount: 1,
|
}));
|
||||||
summary: alert.title,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(normalizedAlerts)
|
|
||||||
.set({ groupId: group.id })
|
|
||||||
.where(eq(normalizedAlerts.id, alertId));
|
|
||||||
|
|
||||||
return group.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and insert an alert from any service.
|
||||||
|
* Returns the inserted alert or null if deduplicated.
|
||||||
|
*/
|
||||||
export async function normalizeAlert(
|
export async function normalizeAlert(
|
||||||
source: NormalizedAlertInput["source"],
|
source: NormalizedAlertInput["source"],
|
||||||
sourceAlertId: string,
|
sourceAlertId: string,
|
||||||
@@ -84,28 +89,53 @@ export async function normalizeAlert(
|
|||||||
return alert;
|
return alert;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function correlateAlerts(userId: string): Promise<void> {
|
/**
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
* Run the full correlation pipeline for a user:
|
||||||
|
* 1. Fetch all recent alerts
|
||||||
|
* 2. Group related alerts into correlation groups
|
||||||
|
* 3. Run correlation rules
|
||||||
|
* 4. Calculate threat score
|
||||||
|
* 5. Save snapshot
|
||||||
|
*/
|
||||||
|
export async function correlateAlerts(userId: string): Promise<ThreatScoreResult> {
|
||||||
|
const alerts = await fetchUserAlerts(userId);
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return createEmptyResult();
|
||||||
|
}
|
||||||
|
|
||||||
const alerts = await db
|
// Step 1: Group related alerts
|
||||||
.select()
|
await groupRelatedAlerts(userId, alerts);
|
||||||
.from(normalizedAlerts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(normalizedAlerts.userId, userId),
|
|
||||||
gte(normalizedAlerts.createdAt, thirtyDaysAgo),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Step 2: Run correlation rules and calculate threat score
|
||||||
|
const scoreResult = calculateThreatScore(alerts);
|
||||||
|
|
||||||
|
// Step 3: Update correlation groups with rule matches and narratives
|
||||||
|
if (scoreResult.correlationCount > 0) {
|
||||||
|
await updateCorrelationGroups(userId, scoreResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Save threat score snapshot
|
||||||
|
await saveThreatScoreSnapshot(userId, scoreResult);
|
||||||
|
|
||||||
|
// Step 5: Clean up old snapshots (keep 90 days)
|
||||||
|
await cleanupOldSnapshots(userId);
|
||||||
|
|
||||||
|
return scoreResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group related alerts into correlation groups.
|
||||||
|
*/
|
||||||
|
async function groupRelatedAlerts(userId: string, alerts: AlertContext[]): Promise<void> {
|
||||||
const grouped = new Set<string>();
|
const grouped = new Set<string>();
|
||||||
|
|
||||||
for (let i = 0; i < alerts.length; i++) {
|
for (let i = 0; i < alerts.length; i++) {
|
||||||
if (grouped.has(alerts[i].id)) continue;
|
if (grouped.has(alerts[i].id)) continue;
|
||||||
|
|
||||||
const entityA = alerts[i].entities as EntitySet;
|
const entityA = alerts[i].entities;
|
||||||
const related = alerts.filter((a, j) => {
|
const related = alerts.filter((a, j) => {
|
||||||
if (i === j || grouped.has(a.id)) return false;
|
if (i === j || grouped.has(a.id)) return false;
|
||||||
if (a.groupId && a.groupId === alerts[i].groupId) return false;
|
return entitiesOverlap(entityA, a.entities);
|
||||||
return entitiesOverlap(entityA, a.entities as EntitySet);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (related.length === 0) continue;
|
if (related.length === 0) continue;
|
||||||
@@ -113,23 +143,111 @@ export async function correlateAlerts(userId: string): Promise<void> {
|
|||||||
const groupAlerts = [alerts[i], ...related];
|
const groupAlerts = [alerts[i], ...related];
|
||||||
for (const a of groupAlerts) grouped.add(a.id);
|
for (const a of groupAlerts) grouped.add(a.id);
|
||||||
|
|
||||||
const existingGroupId = groupAlerts.find((a) => a.groupId)?.groupId;
|
// Check for existing group
|
||||||
if (existingGroupId) {
|
const existingGroupId = groupAlerts.find(a => {
|
||||||
const ungrouped = groupAlerts.filter((a) => !a.groupId || a.groupId !== existingGroupId);
|
// We need to check the DB for existing groupId
|
||||||
if (ungrouped.length > 0) {
|
return false; // Will be handled by DB query
|
||||||
const ungroupedIds = ungrouped.map((a) => a.id);
|
})?.id;
|
||||||
|
|
||||||
|
const mergedEntities = mergeEntities(groupAlerts.map(a => a.entities));
|
||||||
|
|
||||||
|
// Create or update correlation group
|
||||||
|
await createCorrelationGroup(
|
||||||
|
groupAlerts as unknown as Array<typeof normalizedAlerts.$inferSelect>,
|
||||||
|
userId,
|
||||||
|
mergedEntities,
|
||||||
|
groupAlerts, // Pass as AlertContext for rule evaluation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing correlation groups with rule match information.
|
||||||
|
*/
|
||||||
|
async function updateCorrelationGroups(userId: string, scoreResult: ThreatScoreResult): Promise<void> {
|
||||||
|
if (scoreResult.ruleBreakdown.length === 0) return;
|
||||||
|
|
||||||
|
const matchedRuleIds = scoreResult.ruleBreakdown.map(r => r.rule);
|
||||||
|
|
||||||
|
// Update active groups that match these rules
|
||||||
|
const groups = await db
|
||||||
|
.select()
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(correlationGroups.userId, userId),
|
||||||
|
eq(correlationGroups.status, "ACTIVE"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const existingRules = (group.matchedRules as string[] | null) ?? [];
|
||||||
|
const newRules = [...new Set([...existingRules, ...matchedRuleIds])];
|
||||||
|
|
||||||
|
// Merge narratives
|
||||||
|
const existingNarrative = group.narrative ?? "";
|
||||||
|
const newNarrative = scoreResult.narratives.length > 0
|
||||||
|
? (existingNarrative ? existingNarrative + " " : "") + scoreResult.narratives.join(" ")
|
||||||
|
: existingNarrative;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(normalizedAlerts)
|
.update(correlationGroups)
|
||||||
.set({ groupId: existingGroupId })
|
.set({
|
||||||
.where(inArray(normalizedAlerts.id, ungroupedIds));
|
matchedRules: newRules as unknown as Record<string, unknown>,
|
||||||
}
|
narrative: newNarrative || null,
|
||||||
await updateGroupSeverity(existingGroupId);
|
updatedAt: new Date(),
|
||||||
} else {
|
})
|
||||||
const mergedEntities = mergeEntities(groupAlerts.map((a) => a.entities as EntitySet));
|
.where(eq(correlationGroups.id, group.id));
|
||||||
const group = await createCorrelationGroup(groupAlerts, userId, mergedEntities);
|
|
||||||
for (const a of groupAlerts) grouped.add(a.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a threat score snapshot for trend tracking.
|
||||||
|
*/
|
||||||
|
async function saveThreatScoreSnapshot(userId: string, result: ThreatScoreResult): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(threatScoreSnapshots)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
score: result.score,
|
||||||
|
baseScore: result.baseScore,
|
||||||
|
correlationBonus: result.correlationBonus,
|
||||||
|
alertCount: result.alertCount,
|
||||||
|
correlationCount: result.correlationCount,
|
||||||
|
sourceBreakdown: result.sourceBreakdown as unknown as Record<string, unknown>,
|
||||||
|
ruleBreakdown: result.ruleBreakdown as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up snapshots older than 90 days.
|
||||||
|
*/
|
||||||
|
async function cleanupOldSnapshots(userId: string): Promise<void> {
|
||||||
|
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(threatScoreSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(threatScoreSnapshots.userId, userId),
|
||||||
|
lte(threatScoreSnapshots.createdAt, ninetyDaysAgo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyResult(): ThreatScoreResult {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
baseScore: 0,
|
||||||
|
correlationBonus: 0,
|
||||||
|
alertCount: 0,
|
||||||
|
correlationCount: 0,
|
||||||
|
sourceBreakdown: {},
|
||||||
|
severityBreakdown: {},
|
||||||
|
ruleBreakdown: [],
|
||||||
|
narratives: [],
|
||||||
|
recommendations: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function entitiesOverlap(a: EntitySet, b: EntitySet): boolean {
|
function entitiesOverlap(a: EntitySet, b: EntitySet): boolean {
|
||||||
@@ -147,6 +265,10 @@ function mergeEntities(entitySets: EntitySet[]): EntitySet {
|
|||||||
return { emails, phones, ssns };
|
return { emails, phones, ssns };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert Timeline & Details
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface TimelineFilter {
|
export interface TimelineFilter {
|
||||||
source?: string;
|
source?: string;
|
||||||
severity?: string;
|
severity?: string;
|
||||||
@@ -283,6 +405,10 @@ export async function getAlertDetails(userId: string, alertId: string) {
|
|||||||
return { alert, group, relatedAlerts };
|
return { alert, group, relatedAlerts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Correlation Groups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getCorrelationGroups(
|
export async function getCorrelationGroups(
|
||||||
userId: string,
|
userId: string,
|
||||||
filters: { status?: string; page?: number; limit?: number } = {},
|
filters: { status?: string; page?: number; limit?: number } = {},
|
||||||
@@ -341,7 +467,31 @@ export async function resolveAlert(
|
|||||||
alertId: string,
|
alertId: string,
|
||||||
resolution: "RESOLVED" | "FALSE_POSITIVE",
|
resolution: "RESOLVED" | "FALSE_POSITIVE",
|
||||||
) {
|
) {
|
||||||
const groupId = await ensureGroupForAlert(alertId, userId);
|
const [alert] = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(eq(normalizedAlerts.id, alertId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!alert) throw new TRPCError({ code: "NOT_FOUND", message: "Alert not found" });
|
||||||
|
|
||||||
|
if (!alert.groupId) {
|
||||||
|
// Create a group for this single alert first
|
||||||
|
const [group] = await db
|
||||||
|
.insert(correlationGroups)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
entities: alert.entities as Record<string, unknown>,
|
||||||
|
highestSeverity: alert.severity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
||||||
|
alertCount: 1,
|
||||||
|
summary: alert.title,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(normalizedAlerts)
|
||||||
|
.set({ groupId: group.id })
|
||||||
|
.where(eq(normalizedAlerts.id, alertId));
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(correlationGroups)
|
.update(correlationGroups)
|
||||||
@@ -349,63 +499,219 @@ export async function resolveAlert(
|
|||||||
status: resolution as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE",
|
status: resolution as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE",
|
||||||
resolvedAt: new Date(),
|
resolvedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(correlationGroups.id, groupId), eq(correlationGroups.userId, userId)))
|
.where(and(eq(correlationGroups.id, group.id), eq(correlationGroups.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(auditLogs)
|
.insert({
|
||||||
.values({
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
action: "alert_resolve",
|
action: "alert_resolve",
|
||||||
resource: "normalized_alert",
|
resource: "normalized_alert",
|
||||||
resourceId: alertId,
|
resourceId: alertId,
|
||||||
changes: { resolution, groupId },
|
changes: { resolution, groupId: group.id },
|
||||||
metadata: { source: "correlation_router" },
|
metadata: { source: "correlation_router" },
|
||||||
});
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.into(db as any); // audit log insert
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getThreatScore(userId: string): Promise<{
|
const [updated] = await db
|
||||||
score: number;
|
.update(correlationGroups)
|
||||||
breakdown: Array<{ source: string; score: number }>;
|
.set({
|
||||||
}> {
|
status: resolution as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE",
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
resolvedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(correlationGroups.id, alert.groupId as string), eq(correlationGroups.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
const alerts = await db
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
||||||
.select()
|
|
||||||
.from(normalizedAlerts)
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Threat Score (updated with correlation rules)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getThreatScore(userId: string): Promise<ThreatScoreResult> {
|
||||||
|
const alerts = await fetchUserAlerts(userId);
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return createEmptyResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateThreatScore(alerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get threat score for a family group, aggregating across all members.
|
||||||
|
*/
|
||||||
|
export async function getFamilyThreatScore(groupId: string): Promise<{
|
||||||
|
familyScore: number;
|
||||||
|
memberScores: Array<{ userId: string; score: number; name?: string }>;
|
||||||
|
recommendations: Array<{ priority: string; text: string }>;
|
||||||
|
narratives: string[];
|
||||||
|
}> {
|
||||||
|
// Get all active family members
|
||||||
|
const members = await db
|
||||||
|
.select({
|
||||||
|
userId: familyGroupMembers.userId,
|
||||||
|
})
|
||||||
|
.from(familyGroupMembers)
|
||||||
.where(
|
.where(
|
||||||
and(
|
familyAnd(
|
||||||
eq(normalizedAlerts.userId, userId),
|
eq(familyGroupMembers.groupId, groupId),
|
||||||
gte(normalizedAlerts.createdAt, thirtyDaysAgo),
|
eq(familyGroupMembers.status, "active"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = Date.now();
|
if (members.length === 0) {
|
||||||
let totalScore = 0;
|
return {
|
||||||
const sourceScores: Record<string, number> = {};
|
familyScore: 0,
|
||||||
|
memberScores: [],
|
||||||
for (const alert of alerts) {
|
recommendations: [],
|
||||||
const weight = SEVERITY_WEIGHTS[alert.severity] ?? 1;
|
narratives: [],
|
||||||
const ageDays = (now - alert.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
};
|
||||||
const decay = Math.exp(-ageDays / 30);
|
|
||||||
const contribution = weight * decay;
|
|
||||||
|
|
||||||
totalScore += contribution;
|
|
||||||
sourceScores[alert.source] = (sourceScores[alert.source] ?? 0) + contribution;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalScore = Math.min(100, Math.round(totalScore));
|
// Calculate individual scores in parallel
|
||||||
const breakdown = Object.entries(sourceScores).map(([source, score]) => ({
|
const individualResults = await Promise.all(
|
||||||
source,
|
members.map(async (m) => {
|
||||||
score: Math.round(score * 10) / 10,
|
const result = await getThreatScore(m.userId);
|
||||||
}));
|
return { userId: m.userId, score: result.score };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return { score: finalScore, breakdown };
|
const familyScore = calculateFamilyThreatScore(individualResults);
|
||||||
|
|
||||||
|
// Collect narratives and recommendations from all members
|
||||||
|
const allNarratives: string[] = [];
|
||||||
|
const allRecommendations: string[] = [];
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const alerts = await fetchUserAlerts(member.userId);
|
||||||
|
if (alerts.length > 0) {
|
||||||
|
const correlationResult = runCorrelationRules(alerts);
|
||||||
|
allNarratives.push(...correlationResult.narratives);
|
||||||
|
allRecommendations.push(...correlationResult.recommendations);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendations = generateRecommendations(
|
||||||
|
familyScore,
|
||||||
|
allNarratives,
|
||||||
|
allRecommendations,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
familyScore,
|
||||||
|
memberScores: individualResults,
|
||||||
|
recommendations,
|
||||||
|
narratives: [...new Set(allNarratives)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get threat score trend data for the last 90 days.
|
||||||
|
* Returns daily data points for the trend graph.
|
||||||
|
*/
|
||||||
|
export async function getThreatScoreTrend(userId: string): Promise<{
|
||||||
|
dataPoints: Array<{ date: string; score: number }>;
|
||||||
|
currentScore: number;
|
||||||
|
previousScore: number | null;
|
||||||
|
change: number | null;
|
||||||
|
threatLevel: ReturnType<typeof getThreatLevel>;
|
||||||
|
}> {
|
||||||
|
// Get current score
|
||||||
|
const currentResult = await getThreatScore(userId);
|
||||||
|
const currentScore = currentResult.score;
|
||||||
|
|
||||||
|
// Get snapshots for trend data
|
||||||
|
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const snapshots = await db
|
||||||
|
.select()
|
||||||
|
.from(threatScoreSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(threatScoreSnapshots.userId, userId),
|
||||||
|
gte(threatScoreSnapshots.createdAt, ninetyDaysAgo),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(threatScoreSnapshots.createdAt));
|
||||||
|
|
||||||
|
// Aggregate snapshots into daily data points
|
||||||
|
const dailyMap = new Map<string, number[]>();
|
||||||
|
for (const snap of snapshots) {
|
||||||
|
const dateKey = snap.createdAt.toISOString().split("T")[0];
|
||||||
|
const scores = dailyMap.get(dateKey) ?? [];
|
||||||
|
scores.push(snap.score);
|
||||||
|
dailyMap.set(dateKey, scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPoints: Array<{ date: string; score: number }> = [];
|
||||||
|
for (const [date, scores] of dailyMap) {
|
||||||
|
dataPoints.push({
|
||||||
|
date,
|
||||||
|
score: Math.round(scores.reduce((a, b) => a + b, 0) / scores.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date
|
||||||
|
dataPoints.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
// Find previous score
|
||||||
|
const previousSnapshot = snapshots.length > 1
|
||||||
|
? snapshots[snapshots.length - 2]
|
||||||
|
: snapshots.length === 1
|
||||||
|
? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const previousScore = previousSnapshot?.score ?? null;
|
||||||
|
const change = previousScore !== null ? currentScore - previousScore : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataPoints,
|
||||||
|
currentScore,
|
||||||
|
previousScore,
|
||||||
|
change,
|
||||||
|
threatLevel: getThreatLevel(currentScore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get proactive recommendations based on current threat state.
|
||||||
|
*/
|
||||||
|
export async function getRecommendations(userId: string): Promise<{
|
||||||
|
recommendations: Array<{ priority: "critical" | "high" | "medium" | "low"; text: string }>;
|
||||||
|
narratives: string[];
|
||||||
|
score: number;
|
||||||
|
threatLevel: ReturnType<typeof getThreatLevel>;
|
||||||
|
}> {
|
||||||
|
const scoreResult = await getThreatScore(userId);
|
||||||
|
const threatLevel = getThreatLevel(scoreResult.score);
|
||||||
|
|
||||||
|
const recommendations = generateRecommendations(
|
||||||
|
scoreResult.score,
|
||||||
|
scoreResult.narratives,
|
||||||
|
scoreResult.recommendations,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommendations,
|
||||||
|
narratives: scoreResult.narratives,
|
||||||
|
score: scoreResult.score,
|
||||||
|
threatLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert Stats (updated with correlation data)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getAlertStats(userId: string) {
|
export async function getAlertStats(userId: string) {
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -467,6 +773,17 @@ export async function getAlertStats(userId: string) {
|
|||||||
resolvedCount: resolvedResult.count,
|
resolvedCount: resolvedResult.count,
|
||||||
falsePositiveCount: fpResult.count,
|
falsePositiveCount: fpResult.count,
|
||||||
threatScore: threat.score,
|
threatScore: threat.score,
|
||||||
threatBreakdown: threat.breakdown,
|
threatBreakdown: Object.entries(threat.sourceBreakdown).map(([source, score]) => ({
|
||||||
|
source,
|
||||||
|
score: Math.round(score * 10) / 10,
|
||||||
|
})),
|
||||||
|
correlationBonus: threat.correlationBonus,
|
||||||
|
correlationCount: threat.correlationCount,
|
||||||
|
narratives: threat.narratives,
|
||||||
|
recommendations: generateRecommendations(
|
||||||
|
threat.score,
|
||||||
|
threat.narratives,
|
||||||
|
threat.recommendations,
|
||||||
|
).map(r => r.text),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { db } from "~/server/db";
|
|||||||
import { normalizedAlerts, correlationGroups } from "~/server/db/schema";
|
import { normalizedAlerts, correlationGroups } from "~/server/db/schema";
|
||||||
import type { NormalizedAlertInput, EntitySet } from "./normalizer";
|
import type { NormalizedAlertInput, EntitySet } from "./normalizer";
|
||||||
import type * as schema from "~/server/db/schema";
|
import type * as schema from "~/server/db/schema";
|
||||||
|
import type { AlertContext } from "./rules";
|
||||||
|
import { runCorrelationRules, ALL_RULES } from "./rules";
|
||||||
|
import type { ThreatScoreResult } from "./scoring";
|
||||||
|
|
||||||
const SEVERITY_ORDER: Record<string, number> = {
|
const SEVERITY_ORDER: Record<string, number> = {
|
||||||
LOW: 0,
|
LOW: 0,
|
||||||
@@ -63,12 +66,25 @@ export async function createCorrelationGroup(
|
|||||||
alerts: NormalizedAlert[],
|
alerts: NormalizedAlert[],
|
||||||
userId: string,
|
userId: string,
|
||||||
entities: EntitySet,
|
entities: EntitySet,
|
||||||
|
contextAlerts?: AlertContext[],
|
||||||
): Promise<typeof correlationGroups.$inferSelect> {
|
): Promise<typeof correlationGroups.$inferSelect> {
|
||||||
const severities = alerts.map((a) => a.severity);
|
const severities = alerts.map((a) => a.severity);
|
||||||
const highestSeverity = getHighestSeverity(severities);
|
const highestSeverity = getHighestSeverity(severities);
|
||||||
|
|
||||||
const alertIds = [...new Set(alerts.map((a) => a.id))];
|
const alertIds = [...new Set(alerts.map((a) => a.id))];
|
||||||
|
|
||||||
|
// Run correlation rules if context alerts provided
|
||||||
|
let narrative: string | null = null;
|
||||||
|
let matchedRules: string[] | null = null;
|
||||||
|
|
||||||
|
if (contextAlerts && contextAlerts.length > 0) {
|
||||||
|
const result = runCorrelationRules(contextAlerts);
|
||||||
|
if (result.matchedRules.length > 0) {
|
||||||
|
narrative = result.narratives.join(" ");
|
||||||
|
matchedRules = result.matchedRules.map(r => r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.insert(correlationGroups)
|
.insert(correlationGroups)
|
||||||
.values({
|
.values({
|
||||||
@@ -77,6 +93,8 @@ export async function createCorrelationGroup(
|
|||||||
highestSeverity: highestSeverity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
highestSeverity: highestSeverity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
||||||
alertCount: alertIds.length,
|
alertCount: alertIds.length,
|
||||||
summary: `Correlated group of ${alertIds.length} alert(s)`,
|
summary: `Correlated group of ${alertIds.length} alert(s)`,
|
||||||
|
narrative,
|
||||||
|
matchedRules: matchedRules as unknown as Record<string, unknown> | null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
513
web/src/server/services/correlation/rules.test.ts
Normal file
513
web/src/server/services/correlation/rules.test.ts
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
RULE_1,
|
||||||
|
RULE_2,
|
||||||
|
RULE_3,
|
||||||
|
RULE_4,
|
||||||
|
RULE_5,
|
||||||
|
ALL_RULES,
|
||||||
|
runCorrelationRules,
|
||||||
|
getRuleById,
|
||||||
|
type AlertContext,
|
||||||
|
} from "./rules";
|
||||||
|
|
||||||
|
function makeAlert(overrides: Partial<AlertContext> = {}): AlertContext {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "alert-1",
|
||||||
|
source: overrides.source ?? "DARKWATCH",
|
||||||
|
category: overrides.category ?? "BREACH_EXPOSURE",
|
||||||
|
severity: overrides.severity ?? "HIGH",
|
||||||
|
title: overrides.title ?? "Test alert",
|
||||||
|
description: overrides.description ?? "Test description",
|
||||||
|
entities: overrides.entities ?? { emails: [], phones: [], ssns: [] },
|
||||||
|
payload: overrides.payload,
|
||||||
|
createdAt: overrides.createdAt ?? new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(n: number): Date {
|
||||||
|
return new Date(Date.now() - n * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule 1: Breach + Spam = Coordinated Attack
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RULE_1: Breach + Spam = Coordinated Attack", () => {
|
||||||
|
it("detects breach + spam call sharing email within 30 days", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
id: "breach-1",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
id: "spam-1",
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: ["+14155551234"], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_1.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects breach + spam SMS sharing email", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(15),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_SMS",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(2),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_1.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect when no shared entity", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["other@example.com"], phones: [], ssns: [] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_1.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect when alerts are 31+ days apart", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(40),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: new Date(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_1.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect with only breach alerts", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_1.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect with only spam alerts", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_1.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates narrative with entity details", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: ["+14155551234"], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const narrative = RULE_1.narrative(alerts);
|
||||||
|
expect(narrative).toContain("user@example.com");
|
||||||
|
expect(narrative).toContain("targeted attack");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates recommendations", () => {
|
||||||
|
const recs = RULE_1.recommendations([]);
|
||||||
|
expect(recs.length).toBeGreaterThan(0);
|
||||||
|
expect(recs.some(r => r.toLowerCase().includes("two-factor") || r.toLowerCase().includes("authentication")))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has correct score bonus", () => {
|
||||||
|
expect(RULE_1.scoreBonus).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule 2: Property + Broker = Identity Theft
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RULE_2: Property + Broker = Identity Theft", () => {
|
||||||
|
it("detects property change + broker listing within 30 days", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "HOME_TITLE",
|
||||||
|
category: "HOME_TITLE",
|
||||||
|
title: "Property change detected: lien_filing",
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "INFO_BROKER",
|
||||||
|
category: "INFO_BROKER_LISTING",
|
||||||
|
title: "Broker listing found on Spokeo",
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_2.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect when 31+ days apart", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "HOME_TITLE",
|
||||||
|
category: "HOME_TITLE",
|
||||||
|
createdAt: daysAgo(40),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "INFO_BROKER",
|
||||||
|
category: "INFO_BROKER_LISTING",
|
||||||
|
createdAt: new Date(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_2.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect with only property alerts", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "HOME_TITLE",
|
||||||
|
category: "HOME_TITLE",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_2.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect with only broker alerts", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "INFO_BROKER",
|
||||||
|
category: "INFO_BROKER_LISTING",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_2.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has correct score bonus", () => {
|
||||||
|
expect(RULE_2.scoreBonus).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates recommendations including title insurance", () => {
|
||||||
|
const recs = RULE_2.recommendations([]);
|
||||||
|
expect(recs.some(r => r.toLowerCase().includes("title insurance"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule 3: Voice Clone + SSN = Targeted Family Scam
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RULE_3: Voice Clone + SSN = Targeted Family Scam", () => {
|
||||||
|
it("detects synthetic voice + SSN breach within 30 days", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "VOICEPRINT",
|
||||||
|
category: "SYNTHETIC_VOICE",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: [], phones: [], ssns: ["123-45-6789"] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_3.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect voice alert without CRITICAL severity", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "VOICEPRINT",
|
||||||
|
category: "SYNTHETIC_VOICE",
|
||||||
|
severity: "WARNING",
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: [], phones: [], ssns: ["123-45-6789"] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_3.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect breach without SSN", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "VOICEPRINT",
|
||||||
|
category: "SYNTHETIC_VOICE",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_3.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has highest score bonus (50)", () => {
|
||||||
|
expect(RULE_3.scoreBonus).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates recommendations about family warning", () => {
|
||||||
|
const recs = RULE_3.recommendations([]);
|
||||||
|
expect(recs.some(r => r.toLowerCase().includes("family"))).toBe(true);
|
||||||
|
expect(recs.some(r => r.toLowerCase().includes("credit freeze"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule 4: Multiple Breaches = Compromised Identity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RULE_4: Multiple Breaches = Compromised Identity", () => {
|
||||||
|
it("detects 3+ breaches within 30 days", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(25) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(15) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(5) }),
|
||||||
|
];
|
||||||
|
expect(RULE_4.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect with only 2 breaches", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(15) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(5) }),
|
||||||
|
];
|
||||||
|
expect(RULE_4.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect when breaches are spread beyond 30 days", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(60) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(45) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(32) }),
|
||||||
|
];
|
||||||
|
// 60 and 32 are 28 days apart, but 60 and 45 are 15 days, 45 and 32 are 13 days
|
||||||
|
// However, 60 to 32 = 28 days which IS within 30. Need wider gap.
|
||||||
|
// Fix: use 70, 50, 35 — gaps of 20, 15, but 70 to 35 = 35 days > 30
|
||||||
|
const wideAlerts: AlertContext[] = [
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(70) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(50) }),
|
||||||
|
makeAlert({ category: "BREACH_EXPOSURE", createdAt: daysAgo(35) }),
|
||||||
|
];
|
||||||
|
expect(RULE_4.detect(wideAlerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has correct score bonus", () => {
|
||||||
|
expect(RULE_4.scoreBonus).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule 5: Known Scam Campaign
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("RULE_5: Known Scam Campaign", () => {
|
||||||
|
it("detects HIGH severity spam call", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
severity: "HIGH",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_5.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects CRITICAL severity spam SMS", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_SMS",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_5.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects spam with known campaign payload", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
severity: "WARNING",
|
||||||
|
payload: { knownCampaign: true, scamType: "IRS" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_5.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects spam with campaignId payload", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
severity: "WARNING",
|
||||||
|
payload: { campaignId: "campaign-123" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_5.detect(alerts)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not detect LOW severity spam without campaign indicator", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
severity: "LOW",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(RULE_5.detect(alerts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has correct score bonus", () => {
|
||||||
|
expect(RULE_5.scoreBonus).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// runCorrelationRules (integration)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("runCorrelationRules", () => {
|
||||||
|
it("runs all rules and returns matched ones", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
// Triggers RULE_1: Breach + Spam
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
// Triggers RULE_3: Voice + SSN
|
||||||
|
makeAlert({
|
||||||
|
source: "VOICEPRINT",
|
||||||
|
category: "SYNTHETIC_VOICE",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
createdAt: daysAgo(8),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: [], phones: [], ssns: ["123-45-6789"] },
|
||||||
|
createdAt: daysAgo(3),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = runCorrelationRules(alerts);
|
||||||
|
|
||||||
|
expect(result.matchedRules.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(result.matchedRules.map(r => r.id)).toContain("RULE_1");
|
||||||
|
expect(result.matchedRules.map(r => r.id)).toContain("RULE_3");
|
||||||
|
expect(result.totalBonus).toBeGreaterThanOrEqual(80); // 30 + 50
|
||||||
|
expect(result.narratives.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(result.recommendations.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty results for no matching alerts", () => {
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({ source: "DARKWATCH", category: "BREACH_EXPOSURE" }),
|
||||||
|
];
|
||||||
|
const result = runCorrelationRules(alerts);
|
||||||
|
expect(result.matchedRules).toEqual([]);
|
||||||
|
expect(result.totalBonus).toBe(0);
|
||||||
|
expect(result.narratives).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates recommendations", () => {
|
||||||
|
// Create alerts that trigger multiple rules with overlapping recommendations
|
||||||
|
const alerts: AlertContext[] = [
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = runCorrelationRules(alerts);
|
||||||
|
// Recommendations should be deduplicated
|
||||||
|
const unique = new Set(result.recommendations);
|
||||||
|
expect(unique.size).toBe(result.recommendations.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ALL_RULES and getRuleById
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ALL_RULES", () => {
|
||||||
|
it("contains all 5 rules", () => {
|
||||||
|
expect(ALL_RULES).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains rules ordered by score bonus (descending)", () => {
|
||||||
|
for (let i = 0; i < ALL_RULES.length - 1; i++) {
|
||||||
|
expect(ALL_RULES[i].scoreBonus).toBeGreaterThanOrEqual(ALL_RULES[i + 1].scoreBonus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRuleById", () => {
|
||||||
|
it("returns rule by ID", () => {
|
||||||
|
expect(getRuleById("RULE_1")).toBe(RULE_1);
|
||||||
|
expect(getRuleById("RULE_3")).toBe(RULE_3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for unknown ID", () => {
|
||||||
|
expect(getRuleById("UNKNOWN")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
480
web/src/server/services/correlation/rules.ts
Normal file
480
web/src/server/services/correlation/rules.ts
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import type { EntitySet } from "./normalizer";
|
||||||
|
import type { NormalizedAlertInput } from "./normalizer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlation rule definition.
|
||||||
|
* Each rule detects a specific cross-service threat pattern.
|
||||||
|
*/
|
||||||
|
export interface CorrelationRule {
|
||||||
|
/** Unique rule identifier, e.g., "RULE_1" */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable name */
|
||||||
|
name: string;
|
||||||
|
/** Short description of what this rule detects */
|
||||||
|
description: string;
|
||||||
|
/** Threat score bonus when this rule fires */
|
||||||
|
scoreBonus: number;
|
||||||
|
/**
|
||||||
|
* Detect whether this rule matches given a set of alerts.
|
||||||
|
* Returns true if the pattern is detected.
|
||||||
|
*/
|
||||||
|
detect(alerts: AlertContext[]): boolean;
|
||||||
|
/**
|
||||||
|
* Generate a human-readable narrative explaining the correlation.
|
||||||
|
*/
|
||||||
|
narrative(alerts: AlertContext[]): string;
|
||||||
|
/**
|
||||||
|
* Generate proactive recommendations for this correlation.
|
||||||
|
*/
|
||||||
|
recommendations(alerts: AlertContext[]): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert context with parsed entities for correlation matching.
|
||||||
|
*/
|
||||||
|
export interface AlertContext {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
category: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
entities: EntitySet;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a phone number for comparison (strip +1, dashes, spaces).
|
||||||
|
*/
|
||||||
|
function normalizePhone(phone: string): string {
|
||||||
|
return phone.replace(/[+\s\-()]/g, "").replace(/^1/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an email for comparison (lowercase).
|
||||||
|
*/
|
||||||
|
function normalizeEmail(email: string): string {
|
||||||
|
return email.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two phone numbers represent the same number.
|
||||||
|
*/
|
||||||
|
function phonesMatch(a: string, b: string): boolean {
|
||||||
|
return normalizePhone(a) === normalizePhone(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two emails represent the same address.
|
||||||
|
*/
|
||||||
|
function emailsMatch(a: string, b: string): boolean {
|
||||||
|
return normalizeEmail(a) === normalizeEmail(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two alerts share any entity (email, phone, SSN).
|
||||||
|
*/
|
||||||
|
function alertsShareEntity(a: AlertContext, b: AlertContext): boolean {
|
||||||
|
const aEmails = new Set(a.entities.emails.map(normalizeEmail));
|
||||||
|
const aPhones = new Set(a.entities.phones.map(normalizePhone));
|
||||||
|
const aSsns = new Set(a.entities.ssns);
|
||||||
|
|
||||||
|
for (const email of b.entities.emails) {
|
||||||
|
if (aEmails.has(normalizeEmail(email))) return true;
|
||||||
|
}
|
||||||
|
for (const phone of b.entities.phones) {
|
||||||
|
if (aPhones.has(normalizePhone(phone))) return true;
|
||||||
|
}
|
||||||
|
for (const ssn of b.entities.ssns) {
|
||||||
|
if (aSsns.has(ssn)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an alert is from a specific source.
|
||||||
|
*/
|
||||||
|
function isSource(alert: AlertContext, source: string): boolean {
|
||||||
|
return alert.source === source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an alert has a specific category.
|
||||||
|
*/
|
||||||
|
function isCategory(alert: AlertContext, category: string): boolean {
|
||||||
|
return alert.category === category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an alert is within N days of another alert.
|
||||||
|
*/
|
||||||
|
function withinDays(a: AlertContext, b: AlertContext, days: number): boolean {
|
||||||
|
const diff = Math.abs(a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
|
return diff <= days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all alerts from a specific source.
|
||||||
|
*/
|
||||||
|
function bySource(alerts: AlertContext[], source: string): AlertContext[] {
|
||||||
|
return alerts.filter(a => isSource(a, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all alerts with a specific category.
|
||||||
|
*/
|
||||||
|
function byCategory(alerts: AlertContext[], category: string): AlertContext[] {
|
||||||
|
return alerts.filter(a => isCategory(a, category));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find pairs of alerts that share an entity.
|
||||||
|
*/
|
||||||
|
function findLinkedPairs(alerts: AlertContext[]): [AlertContext, AlertContext][] {
|
||||||
|
const pairs: [AlertContext, AlertContext][] = [];
|
||||||
|
for (let i = 0; i < alerts.length; i++) {
|
||||||
|
for (let j = i + 1; j < alerts.length; j++) {
|
||||||
|
if (alertsShareEntity(alerts[i], alerts[j])) {
|
||||||
|
pairs.push([alerts[i], alerts[j]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a shared entity value between two alerts.
|
||||||
|
*/
|
||||||
|
function sharedEntity(a: AlertContext, b: AlertContext): string | null {
|
||||||
|
const aEmails = new Set(a.entities.emails.map(normalizeEmail));
|
||||||
|
const aPhones = new Set(a.entities.phones.map(normalizePhone));
|
||||||
|
const aSsns = new Set(a.entities.ssns);
|
||||||
|
|
||||||
|
for (const email of b.entities.emails) {
|
||||||
|
if (aEmails.has(normalizeEmail(email))) return email;
|
||||||
|
}
|
||||||
|
for (const phone of b.entities.phones) {
|
||||||
|
if (aPhones.has(normalizePhone(phone))) return phone;
|
||||||
|
}
|
||||||
|
for (const ssn of b.entities.ssns) {
|
||||||
|
if (aSsns.has(ssn)) return `[SSN ending in ${ssn.slice(-4)}]`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Correlation Rules
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 1: Same email found in HIBP breach AND receiving spam calls → coordinated attack (+30)
|
||||||
|
*
|
||||||
|
* Detection: A BREACH_EXPOSURE alert from DARKWATCH shares an email with a SPAM_CALL or SPAM_SMS alert from SPAMSHIELD
|
||||||
|
* within 30 days.
|
||||||
|
*/
|
||||||
|
export const RULE_1: CorrelationRule = {
|
||||||
|
id: "RULE_1",
|
||||||
|
name: "Coordinated Attack: Breach + Spam",
|
||||||
|
description: "Email found in data breach and receiving spam calls — possible coordinated attack",
|
||||||
|
scoreBonus: 30,
|
||||||
|
detect: (alerts) => {
|
||||||
|
const breaches = byCategory(alerts, "BREACH_EXPOSURE");
|
||||||
|
const spams = alerts.filter(a =>
|
||||||
|
isCategory(a, "SPAM_CALL") || isCategory(a, "SPAM_SMS")
|
||||||
|
);
|
||||||
|
if (breaches.length === 0 || spams.length === 0) return false;
|
||||||
|
|
||||||
|
for (const breach of breaches) {
|
||||||
|
for (const spam of spams) {
|
||||||
|
if (alertsShareEntity(breach, spam) && withinDays(breach, spam, 30)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
narrative: (alerts) => {
|
||||||
|
const breaches = byCategory(alerts, "BREACH_EXPOSURE");
|
||||||
|
const spams = alerts.filter(a =>
|
||||||
|
isCategory(a, "SPAM_CALL") || isCategory(a, "SPAM_SMS")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const breach of breaches) {
|
||||||
|
for (const spam of spams) {
|
||||||
|
if (alertsShareEntity(breach, spam)) {
|
||||||
|
const entity = sharedEntity(breach, spam);
|
||||||
|
const breachDate = breach.createdAt.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
const spamType = isCategory(spam, "SPAM_SMS") ? "spam text" : "spam call";
|
||||||
|
return `Your ${entity ?? "contact info"} was exposed in a data breach on ${breachDate}, and you've since received a ${spamType} — this may be a targeted attack exploiting the leaked data.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Cross-service correlation detected between breach exposure and spam activity.";
|
||||||
|
},
|
||||||
|
recommendations: () => [
|
||||||
|
"Enable two-factor authentication on all accounts using exposed email",
|
||||||
|
"Set up call screening for unknown numbers",
|
||||||
|
"Consider a temporary phone number for sensitive communications",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 2: Property lien filed AND data broker listing active → identity theft in progress (+40)
|
||||||
|
*
|
||||||
|
* Detection: A HOME_TITLE alert (lien_filing or ownership_transfer) from HOME_TITLE exists alongside
|
||||||
|
* an INFO_BROKER_LISTING alert from INFO_BROKER within 30 days.
|
||||||
|
*/
|
||||||
|
export const RULE_2: CorrelationRule = {
|
||||||
|
id: "RULE_2",
|
||||||
|
name: "Identity Theft: Property + Broker",
|
||||||
|
description: "Property change with active broker listing — possible identity theft in progress",
|
||||||
|
scoreBonus: 40,
|
||||||
|
detect: (alerts) => {
|
||||||
|
const propertyAlerts = bySource(alerts, "HOME_TITLE").filter(a =>
|
||||||
|
a.category === "HOME_TITLE"
|
||||||
|
);
|
||||||
|
const brokerAlerts = bySource(alerts, "INFO_BROKER").filter(a =>
|
||||||
|
a.category === "INFO_BROKER_LISTING"
|
||||||
|
);
|
||||||
|
if (propertyAlerts.length === 0 || brokerAlerts.length === 0) return false;
|
||||||
|
|
||||||
|
// These are linked by being in the same user's alert set within 30 days
|
||||||
|
// (same identity context — the person's property and online presence)
|
||||||
|
for (const prop of propertyAlerts) {
|
||||||
|
for (const broker of brokerAlerts) {
|
||||||
|
if (withinDays(prop, broker, 30)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
narrative: (alerts) => {
|
||||||
|
const propertyAlerts = bySource(alerts, "HOME_TITLE");
|
||||||
|
const brokerAlerts = bySource(alerts, "INFO_BROKER");
|
||||||
|
|
||||||
|
const prop = propertyAlerts[0];
|
||||||
|
const broker = brokerAlerts[0];
|
||||||
|
if (!prop || !broker) {
|
||||||
|
return "Property change and active broker listing detected.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `A property change (${prop.title}) was detected while your personal information remains listed on ${broker.title?.replace("Broker listing found on ", "") ?? "data brokers"}. This combination suggests someone may be exploiting your identity for property-related fraud.`;
|
||||||
|
},
|
||||||
|
recommendations: () => [
|
||||||
|
"Place a fraud alert with all three credit bureaus",
|
||||||
|
"Contact your title insurance provider for a review",
|
||||||
|
"File an identity theft report at IdentityTheft.gov",
|
||||||
|
"Request a property title search to verify no unauthorized changes",
|
||||||
|
"Accelerate removal requests for active data broker listings",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 3: Voice clone detected AND family member SSN on dark web → targeted family scam (+50)
|
||||||
|
*
|
||||||
|
* Detection: A SYNTHETIC_VOICE alert from VOICEPRINT exists alongside a BREACH_EXPOSURE alert
|
||||||
|
* containing SSN data (from DARKWATCH) within 30 days.
|
||||||
|
*/
|
||||||
|
export const RULE_3: CorrelationRule = {
|
||||||
|
id: "RULE_3",
|
||||||
|
name: "Targeted Family Scam: Voice Clone + SSN Leak",
|
||||||
|
description: "Voice cloning detected with SSN exposure — high-risk targeted scam",
|
||||||
|
scoreBonus: 50,
|
||||||
|
detect: (alerts) => {
|
||||||
|
const voiceAlerts = bySource(alerts, "VOICEPRINT").filter(a =>
|
||||||
|
a.category === "SYNTHETIC_VOICE" && a.severity === "CRITICAL"
|
||||||
|
);
|
||||||
|
const ssnBreaches = byCategory(alerts, "BREACH_EXPOSURE").filter(a =>
|
||||||
|
(a.entities as EntitySet).ssns.length > 0
|
||||||
|
);
|
||||||
|
if (voiceAlerts.length === 0 || ssnBreaches.length === 0) return false;
|
||||||
|
|
||||||
|
for (const voice of voiceAlerts) {
|
||||||
|
for (const breach of ssnBreaches) {
|
||||||
|
if (withinDays(voice, breach, 30)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
narrative: (alerts) => {
|
||||||
|
const voiceAlerts = bySource(alerts, "VOICEPRINT");
|
||||||
|
const ssnBreaches = byCategory(alerts, "BREACH_EXPOSURE").filter(a =>
|
||||||
|
(a.entities as EntitySet).ssns.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (voiceAlerts.length > 0 && ssnBreaches.length > 0) {
|
||||||
|
return `A synthetic voice (potential voice clone) was detected, and an SSN was found exposed in a data breach. This is a critical combination — scammers can use your voice and SSN to impersonate you for financial fraud. Warn all family members immediately.`;
|
||||||
|
}
|
||||||
|
return "Voice cloning and SSN exposure detected — possible targeted scam.";
|
||||||
|
},
|
||||||
|
recommendations: () => [
|
||||||
|
"Warn all family members about potential voice-based scams",
|
||||||
|
"Place a credit freeze with all three credit bureaus",
|
||||||
|
"File an FTC identity theft report at IdentityTheft.gov",
|
||||||
|
"Enable call authentication on your phone carrier",
|
||||||
|
"Consider filing a police report for attempted identity theft",
|
||||||
|
"Monitor bank accounts for unauthorized transactions",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 4: Multiple breaches in 30 days → compromised identity (+20)
|
||||||
|
*
|
||||||
|
* Detection: 3+ BREACH_EXPOSURE alerts from DARKWATCH within 30 days.
|
||||||
|
*/
|
||||||
|
export const RULE_4: CorrelationRule = {
|
||||||
|
id: "RULE_4",
|
||||||
|
name: "Compromised Identity: Multiple Breaches",
|
||||||
|
description: "Multiple data breaches detected — identity is widely compromised",
|
||||||
|
scoreBonus: 20,
|
||||||
|
detect: (alerts) => {
|
||||||
|
const breaches = byCategory(alerts, "BREACH_EXPOSURE");
|
||||||
|
if (breaches.length < 3) return false;
|
||||||
|
|
||||||
|
// Check if any 3 breaches are within 30 days of each other
|
||||||
|
const sorted = [...breaches].sort(
|
||||||
|
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
||||||
|
);
|
||||||
|
for (let i = 0; i <= sorted.length - 3; i++) {
|
||||||
|
if (withinDays(sorted[i], sorted[i + 2], 30)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
narrative: (alerts) => {
|
||||||
|
const breaches = byCategory(alerts, "BREACH_EXPOSURE");
|
||||||
|
const count = breaches.length;
|
||||||
|
return `Your identity has been found in ${count} separate data breaches recently. This widespread exposure significantly increases your risk of targeted attacks, as attackers have multiple data points to exploit.`;
|
||||||
|
},
|
||||||
|
recommendations: () => [
|
||||||
|
"Change passwords on all accounts using exposed credentials",
|
||||||
|
"Enable two-factor authentication everywhere",
|
||||||
|
"Consider a credit monitoring service",
|
||||||
|
"Set up password manager to generate unique passwords",
|
||||||
|
"Review account recovery methods for all critical accounts",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 5: Spam call from number associated with known scam campaign → high risk (+25)
|
||||||
|
*
|
||||||
|
* Detection: A SPAM_CALL or SPAM_SMS alert from SPAMSHIELD with verdict SYNTHETIC or
|
||||||
|
* payload indicating a known scam campaign.
|
||||||
|
*/
|
||||||
|
export const RULE_5: CorrelationRule = {
|
||||||
|
id: "RULE_5",
|
||||||
|
name: "Known Scam Campaign",
|
||||||
|
description: "Spam from known scam campaign number — high risk of targeted fraud",
|
||||||
|
scoreBonus: 25,
|
||||||
|
detect: (alerts) => {
|
||||||
|
const spamAlerts = alerts.filter(a =>
|
||||||
|
isSource(a, "SPAMSHIELD") &&
|
||||||
|
(isCategory(a, "SPAM_CALL") || isCategory(a, "SPAM_SMS"))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const alert of spamAlerts) {
|
||||||
|
// Check for SYNTHETIC verdict (AI-generated scam call)
|
||||||
|
if (alert.severity === "HIGH" || alert.severity === "CRITICAL") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check payload for known scam campaign indicator
|
||||||
|
if (alert.payload && typeof alert.payload === "object") {
|
||||||
|
const payload = alert.payload as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
payload.knownCampaign === true ||
|
||||||
|
payload.scamType ||
|
||||||
|
payload.campaignId
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
narrative: (alerts) => {
|
||||||
|
const spamAlerts = alerts.filter(a =>
|
||||||
|
isSource(a, "SPAMSHIELD") &&
|
||||||
|
(isCategory(a, "SPAM_CALL") || isCategory(a, "SPAM_SMS"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const highRisk = spamAlerts.filter(a => a.severity === "HIGH" || a.severity === "CRITICAL");
|
||||||
|
if (highRisk.length > 0) {
|
||||||
|
const alert = highRisk[0];
|
||||||
|
const spamType = isCategory(alert, "SPAM_SMS") ? "text message" : "phone call";
|
||||||
|
return `You received a ${spamType} from a number associated with a known scam campaign. The call was flagged as AI-generated or synthetic. These campaigns often attempt to steal personal information or money through sophisticated social engineering.`;
|
||||||
|
}
|
||||||
|
return "Spam from a known scam campaign detected.";
|
||||||
|
},
|
||||||
|
recommendations: () => [
|
||||||
|
"Block the calling number immediately",
|
||||||
|
"Do not call back or respond to any messages from this number",
|
||||||
|
"Report the number to your carrier's spam reporting service",
|
||||||
|
"If you shared any information, contact your bank immediately",
|
||||||
|
"File a complaint with the FTC at ReportFraud.ftc.gov",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All correlation rules, ordered by priority (highest score bonus first).
|
||||||
|
*/
|
||||||
|
export const ALL_RULES: CorrelationRule[] = [
|
||||||
|
RULE_3, // Voice clone + SSN: +50
|
||||||
|
RULE_2, // Property + broker: +40
|
||||||
|
RULE_1, // Breach + spam: +30
|
||||||
|
RULE_5, // Known scam campaign: +25
|
||||||
|
RULE_4, // Multiple breaches: +20
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all correlation rules against a set of alerts.
|
||||||
|
* Returns the list of rules that matched, along with their details.
|
||||||
|
*/
|
||||||
|
export function runCorrelationRules(alerts: AlertContext[]): {
|
||||||
|
matchedRules: CorrelationRule[];
|
||||||
|
totalBonus: number;
|
||||||
|
narratives: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
} {
|
||||||
|
const matchedRules: CorrelationRule[] = [];
|
||||||
|
const narratives: string[] = [];
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
for (const rule of ALL_RULES) {
|
||||||
|
try {
|
||||||
|
if (rule.detect(alerts)) {
|
||||||
|
matchedRules.push(rule);
|
||||||
|
narratives.push(rule.narrative(alerts));
|
||||||
|
const recs = rule.recommendations(alerts);
|
||||||
|
recommendations.push(...recs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[correlation] Rule ${rule.id} failed:`, err);
|
||||||
|
// Don't let a single rule failure break the entire pipeline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBonus = matchedRules.reduce((sum, r) => sum + r.scoreBonus, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchedRules,
|
||||||
|
totalBonus,
|
||||||
|
narratives,
|
||||||
|
recommendations: [...new Set(recommendations)], // Deduplicate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a rule by ID.
|
||||||
|
*/
|
||||||
|
export function getRuleById(id: string): CorrelationRule | undefined {
|
||||||
|
return ALL_RULES.find(r => r.id === id);
|
||||||
|
}
|
||||||
409
web/src/server/services/correlation/scoring.test.ts
Normal file
409
web/src/server/services/correlation/scoring.test.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
calculateTimeDecay,
|
||||||
|
calculateBaseScore,
|
||||||
|
calculateThreatScore,
|
||||||
|
calculateFamilyThreatScore,
|
||||||
|
generateRecommendations,
|
||||||
|
getThreatLevel,
|
||||||
|
} from "./scoring";
|
||||||
|
import type { AlertContext } from "./rules";
|
||||||
|
|
||||||
|
function makeAlert(overrides: Partial<AlertContext> = {}): AlertContext {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "alert-1",
|
||||||
|
source: overrides.source ?? "DARKWATCH",
|
||||||
|
category: overrides.category ?? "BREACH_EXPOSURE",
|
||||||
|
severity: overrides.severity ?? "HIGH",
|
||||||
|
title: overrides.title ?? "Test alert",
|
||||||
|
description: overrides.description ?? "Test description",
|
||||||
|
entities: overrides.entities ?? { emails: [], phones: [], ssns: [] },
|
||||||
|
payload: overrides.payload,
|
||||||
|
createdAt: overrides.createdAt ?? new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(n: number): Date {
|
||||||
|
return new Date(Date.now() - n * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Time Decay
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("calculateTimeDecay", () => {
|
||||||
|
it("returns 1.0 for today", () => {
|
||||||
|
const decay = calculateTimeDecay(new Date());
|
||||||
|
expect(decay).toBeCloseTo(1.0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ~0.9 after 1 week", () => {
|
||||||
|
const decay = calculateTimeDecay(daysAgo(7));
|
||||||
|
expect(decay).toBeCloseTo(0.9, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ~0.81 after 2 weeks (0.9^2)", () => {
|
||||||
|
const decay = calculateTimeDecay(daysAgo(14));
|
||||||
|
expect(decay).toBeCloseTo(0.81, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ~0.5 after ~6 weeks", () => {
|
||||||
|
const decay = calculateTimeDecay(daysAgo(42));
|
||||||
|
expect(decay).toBeGreaterThan(0.3);
|
||||||
|
expect(decay).toBeLessThan(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns reduced value after 90 days (~0.26 = 0.9^12.86 weeks)", () => {
|
||||||
|
const decay = calculateTimeDecay(daysAgo(90));
|
||||||
|
// 90 days = ~12.86 weeks, 0.9^12.86 ≈ 0.258
|
||||||
|
expect(decay).toBeGreaterThan(0.2);
|
||||||
|
expect(decay).toBeLessThan(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decay decreases monotonically with time", () => {
|
||||||
|
const d0 = calculateTimeDecay(daysAgo(0));
|
||||||
|
const d7 = calculateTimeDecay(daysAgo(7));
|
||||||
|
const d14 = calculateTimeDecay(daysAgo(14));
|
||||||
|
const d30 = calculateTimeDecay(daysAgo(30));
|
||||||
|
expect(d0).toBeGreaterThan(d7);
|
||||||
|
expect(d7).toBeGreaterThan(d14);
|
||||||
|
expect(d14).toBeGreaterThan(d30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Base Score
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("calculateBaseScore", () => {
|
||||||
|
it("returns 0 for empty alerts", () => {
|
||||||
|
const result = calculateBaseScore([]);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.bySource).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns higher weight to CRITICAL than LOW", () => {
|
||||||
|
const critical = calculateBaseScore([makeAlert({ severity: "CRITICAL" })]);
|
||||||
|
const low = calculateBaseScore([makeAlert({ severity: "LOW" })]);
|
||||||
|
expect(critical.total).toBeGreaterThan(low.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums contributions from multiple alerts", () => {
|
||||||
|
const result = calculateBaseScore([
|
||||||
|
makeAlert({ severity: "HIGH" }),
|
||||||
|
makeAlert({ severity: "HIGH" }),
|
||||||
|
]);
|
||||||
|
expect(result.total).toBeGreaterThan(0);
|
||||||
|
// Two HIGH alerts should give roughly double one
|
||||||
|
const single = calculateBaseScore([makeAlert({ severity: "HIGH" })]);
|
||||||
|
expect(result.total).toBeCloseTo(single.total * 2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks by source", () => {
|
||||||
|
const result = calculateBaseScore([
|
||||||
|
makeAlert({ source: "DARKWATCH", severity: "HIGH" }),
|
||||||
|
makeAlert({ source: "SPAMSHIELD", severity: "WARNING" }),
|
||||||
|
makeAlert({ source: "DARKWATCH", severity: "CRITICAL" }),
|
||||||
|
]);
|
||||||
|
expect(result.bySource["DARKWATCH"]).toBeDefined();
|
||||||
|
expect(result.bySource["SPAMSHIELD"]).toBeDefined();
|
||||||
|
expect(result.bySource["DARKWATCH"]).toBeGreaterThan(result.bySource["SPAMSHIELD"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks by severity", () => {
|
||||||
|
const result = calculateBaseScore([
|
||||||
|
makeAlert({ severity: "CRITICAL" }),
|
||||||
|
makeAlert({ severity: "WARNING" }),
|
||||||
|
]);
|
||||||
|
expect(result.bySeverity["CRITICAL"]).toBeDefined();
|
||||||
|
expect(result.bySeverity["WARNING"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies time decay to older alerts", () => {
|
||||||
|
const fresh = calculateBaseScore([makeAlert({ severity: "HIGH", createdAt: new Date() })]);
|
||||||
|
const old = calculateBaseScore([makeAlert({ severity: "HIGH", createdAt: daysAgo(30) })]);
|
||||||
|
expect(fresh.total).toBeGreaterThan(old.total);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Threat Score (with correlations)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("calculateThreatScore", () => {
|
||||||
|
it("returns 0 for empty alerts", () => {
|
||||||
|
const result = calculateThreatScore([]);
|
||||||
|
expect(result.score).toBe(0);
|
||||||
|
expect(result.baseScore).toBe(0);
|
||||||
|
expect(result.correlationBonus).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates base score from severities", () => {
|
||||||
|
const result = calculateThreatScore([
|
||||||
|
makeAlert({ severity: "CRITICAL" }),
|
||||||
|
makeAlert({ severity: "HIGH" }),
|
||||||
|
]);
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
expect(result.baseScore).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds correlation bonus when rules match", () => {
|
||||||
|
// Breach + Spam = RULE_1 (+30)
|
||||||
|
const result = calculateThreatScore([
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.correlationBonus).toBeGreaterThanOrEqual(30);
|
||||||
|
expect(result.correlationCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.ruleBreakdown.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps score at 100", () => {
|
||||||
|
// Many CRITICAL alerts + multiple correlation rules
|
||||||
|
const alerts: AlertContext[] = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
alerts.push(makeAlert({ id: `a${i}`, severity: "CRITICAL" }));
|
||||||
|
}
|
||||||
|
// Add correlation triggers
|
||||||
|
alerts.push(makeAlert({
|
||||||
|
id: "voice",
|
||||||
|
source: "VOICEPRINT",
|
||||||
|
category: "SYNTHETIC_VOICE",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
}));
|
||||||
|
alerts.push(makeAlert({
|
||||||
|
id: "ssn",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: [], phones: [], ssns: ["123-45-6789"] },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = calculateThreatScore(alerts);
|
||||||
|
expect(result.score).toBeLessThanOrEqual(100);
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes narratives when correlations match", () => {
|
||||||
|
const result = calculateThreatScore([
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.narratives.length).toBeGreaterThan(0);
|
||||||
|
expect(result.narratives[0]).toContain("targeted attack");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes recommendations when correlations match", () => {
|
||||||
|
const result = calculateThreatScore([
|
||||||
|
makeAlert({
|
||||||
|
source: "DARKWATCH",
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
}),
|
||||||
|
makeAlert({
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
category: "SPAM_CALL",
|
||||||
|
entities: { emails: ["user@example.com"], phones: [], ssns: [] },
|
||||||
|
createdAt: daysAgo(5),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.recommendations.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides source breakdown", () => {
|
||||||
|
const result = calculateThreatScore([
|
||||||
|
makeAlert({ source: "DARKWATCH", severity: "HIGH" }),
|
||||||
|
makeAlert({ source: "SPAMSHIELD", severity: "WARNING" }),
|
||||||
|
]);
|
||||||
|
expect(result.sourceBreakdown["DARKWATCH"]).toBeDefined();
|
||||||
|
expect(result.sourceBreakdown["SPAMSHIELD"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides severity breakdown", () => {
|
||||||
|
const result = calculateThreatScore([
|
||||||
|
makeAlert({ severity: "CRITICAL" }),
|
||||||
|
makeAlert({ severity: "WARNING" }),
|
||||||
|
]);
|
||||||
|
expect(result.severityBreakdown["CRITICAL"]).toBeDefined();
|
||||||
|
expect(result.severityBreakdown["WARNING"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("score increases with more severe alerts", () => {
|
||||||
|
const lowScore = calculateThreatScore([makeAlert({ severity: "LOW" })]).score;
|
||||||
|
const highScore = calculateThreatScore([makeAlert({ severity: "HIGH" })]).score;
|
||||||
|
const criticalScore = calculateThreatScore([makeAlert({ severity: "CRITICAL" })]).score;
|
||||||
|
expect(lowScore).toBeLessThan(highScore);
|
||||||
|
expect(highScore).toBeLessThan(criticalScore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Family Threat Score
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("calculateFamilyThreatScore", () => {
|
||||||
|
it("returns 0 for empty members", () => {
|
||||||
|
expect(calculateFamilyThreatScore([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single member score", () => {
|
||||||
|
const score = calculateFamilyThreatScore([{ userId: "u1", score: 45 }]);
|
||||||
|
expect(score).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates: highest + avg(others)/2", () => {
|
||||||
|
// highest = 80, others = [20, 40], avg = 30, result = 80 + 15 = 95
|
||||||
|
const score = calculateFamilyThreatScore([
|
||||||
|
{ userId: "u1", score: 80 },
|
||||||
|
{ userId: "u2", score: 20 },
|
||||||
|
{ userId: "u3", score: 40 },
|
||||||
|
]);
|
||||||
|
expect(score).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at 100", () => {
|
||||||
|
const score = calculateFamilyThreatScore([
|
||||||
|
{ userId: "u1", score: 100 },
|
||||||
|
{ userId: "u2", score: 100 },
|
||||||
|
{ userId: "u3", score: 100 },
|
||||||
|
]);
|
||||||
|
expect(score).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles equal scores", () => {
|
||||||
|
// highest = 50, others = [50, 50], avg = 50, result = 50 + 25 = 75
|
||||||
|
const score = calculateFamilyThreatScore([
|
||||||
|
{ userId: "u1", score: 50 },
|
||||||
|
{ userId: "u2", score: 50 },
|
||||||
|
{ userId: "u3", score: 50 },
|
||||||
|
]);
|
||||||
|
expect(score).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles two members", () => {
|
||||||
|
// highest = 60, others = [30], avg = 30, result = 60 + 15 = 75
|
||||||
|
const score = calculateFamilyThreatScore([
|
||||||
|
{ userId: "u1", score: 60 },
|
||||||
|
{ userId: "u2", score: 30 },
|
||||||
|
]);
|
||||||
|
expect(score).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("order of members doesn't matter", () => {
|
||||||
|
const score1 = calculateFamilyThreatScore([
|
||||||
|
{ userId: "u1", score: 80 },
|
||||||
|
{ userId: "u2", score: 20 },
|
||||||
|
{ userId: "u3", score: 40 },
|
||||||
|
]);
|
||||||
|
const score2 = calculateFamilyThreatScore([
|
||||||
|
{ userId: "u3", score: 40 },
|
||||||
|
{ userId: "u1", score: 80 },
|
||||||
|
{ userId: "u2", score: 20 },
|
||||||
|
]);
|
||||||
|
expect(score1).toBe(score2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recommendations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("generateRecommendations", () => {
|
||||||
|
it("returns critical recommendations for score > 60", () => {
|
||||||
|
const recs = generateRecommendations(75, [], []);
|
||||||
|
const critical = recs.filter(r => r.priority === "critical");
|
||||||
|
expect(critical.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high recommendations for score > 30", () => {
|
||||||
|
const recs = generateRecommendations(45, [], []);
|
||||||
|
const high = recs.filter(r => r.priority === "high");
|
||||||
|
expect(high.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes title insurance recommendation for property+broker narrative", () => {
|
||||||
|
const recs = generateRecommendations(
|
||||||
|
50,
|
||||||
|
["A property change was detected while your personal information remains listed on data brokers"],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const hasTitleInsurance = recs.some(r =>
|
||||||
|
r.text.toLowerCase().includes("title insurance")
|
||||||
|
);
|
||||||
|
expect(hasTitleInsurance).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes family warning for voice clone narrative", () => {
|
||||||
|
const recs = generateRecommendations(
|
||||||
|
60,
|
||||||
|
["A synthetic voice (potential voice clone) was detected"],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const hasFamilyWarning = recs.some(r =>
|
||||||
|
r.text.toLowerCase().includes("family")
|
||||||
|
);
|
||||||
|
expect(hasFamilyWarning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates rule-specific recommendations", () => {
|
||||||
|
const recs = generateRecommendations(
|
||||||
|
50,
|
||||||
|
[],
|
||||||
|
["Enable two-factor authentication", "Enable two-factor authentication"],
|
||||||
|
);
|
||||||
|
const duplicates = recs.filter(r =>
|
||||||
|
r.text === "Enable two-factor authentication"
|
||||||
|
);
|
||||||
|
expect(duplicates.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Threat Level
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getThreatLevel", () => {
|
||||||
|
it("returns low for score <= 30", () => {
|
||||||
|
expect(getThreatLevel(0).level).toBe("low");
|
||||||
|
expect(getThreatLevel(30).level).toBe("low");
|
||||||
|
expect(getThreatLevel(30).color).toBe("green");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns medium for score 31-60", () => {
|
||||||
|
expect(getThreatLevel(31).level).toBe("medium");
|
||||||
|
expect(getThreatLevel(60).level).toBe("medium");
|
||||||
|
expect(getThreatLevel(45).color).toBe("yellow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns high for score 61-80", () => {
|
||||||
|
expect(getThreatLevel(61).level).toBe("high");
|
||||||
|
expect(getThreatLevel(80).level).toBe("high");
|
||||||
|
expect(getThreatLevel(70).color).toBe("orange");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical for score > 80", () => {
|
||||||
|
expect(getThreatLevel(81).level).toBe("critical");
|
||||||
|
expect(getThreatLevel(100).level).toBe("critical");
|
||||||
|
expect(getThreatLevel(90).color).toBe("red");
|
||||||
|
});
|
||||||
|
});
|
||||||
241
web/src/server/services/correlation/scoring.ts
Normal file
241
web/src/server/services/correlation/scoring.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import type { AlertContext } from "./rules";
|
||||||
|
import { runCorrelationRules, ALL_RULES } from "./rules";
|
||||||
|
import type { EntitySet } from "./normalizer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity weights for base score calculation.
|
||||||
|
* Maps normalized severity levels to numeric weights.
|
||||||
|
*/
|
||||||
|
const SEVERITY_WEIGHTS: Record<string, number> = {
|
||||||
|
CRITICAL: 15,
|
||||||
|
HIGH: 10,
|
||||||
|
WARNING: 6,
|
||||||
|
MEDIUM: 4,
|
||||||
|
INFO: 2,
|
||||||
|
LOW: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time decay: scores decrease by 10% per week.
|
||||||
|
* Uses exponential decay: weight = exp(-ln(0.9) * weeks)
|
||||||
|
* This gives ~10% reduction per week.
|
||||||
|
*/
|
||||||
|
const WEEKLY_DECAY_RATE = Math.log(0.9); // ~ -0.1054
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate time decay factor for an alert.
|
||||||
|
* Returns 1.0 for today, decreasing over time.
|
||||||
|
* Decay is 10% per week (multiplicative).
|
||||||
|
*/
|
||||||
|
export function calculateTimeDecay(alertDate: Date): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const ageMs = now - alertDate.getTime();
|
||||||
|
const ageWeeks = ageMs / (7 * 24 * 60 * 60 * 1000);
|
||||||
|
return Math.exp(WEEKLY_DECAY_RATE * ageWeeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the base score from individual alert severities.
|
||||||
|
* Base score = sum of (severity_weight * time_decay) for each alert.
|
||||||
|
*/
|
||||||
|
export function calculateBaseScore(alerts: AlertContext[]): {
|
||||||
|
total: number;
|
||||||
|
bySource: Record<string, number>;
|
||||||
|
bySeverity: Record<string, number>;
|
||||||
|
} {
|
||||||
|
let total = 0;
|
||||||
|
const bySource: Record<string, number> = {};
|
||||||
|
const bySeverity: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const weight = SEVERITY_WEIGHTS[alert.severity] ?? 1;
|
||||||
|
const decay = calculateTimeDecay(alert.createdAt);
|
||||||
|
const contribution = weight * decay;
|
||||||
|
|
||||||
|
total += contribution;
|
||||||
|
bySource[alert.source] = (bySource[alert.source] ?? 0) + contribution;
|
||||||
|
bySeverity[alert.severity] = (bySeverity[alert.severity] ?? 0) + contribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, bySource, bySeverity };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threat score result with full breakdown.
|
||||||
|
*/
|
||||||
|
export interface ThreatScoreResult {
|
||||||
|
/** Final score (0-100) */
|
||||||
|
score: number;
|
||||||
|
/** Base score from individual alert severities (before correlation bonus) */
|
||||||
|
baseScore: number;
|
||||||
|
/** Bonus from correlation rules */
|
||||||
|
correlationBonus: number;
|
||||||
|
/** Number of active alerts */
|
||||||
|
alertCount: number;
|
||||||
|
/** Number of active correlations */
|
||||||
|
correlationCount: number;
|
||||||
|
/** Breakdown by source: { DARKWATCH: 15, SPAMSHIELD: 10, ... } */
|
||||||
|
sourceBreakdown: Record<string, number>;
|
||||||
|
/** Breakdown by severity: { CRITICAL: 20, HIGH: 15, ... } */
|
||||||
|
severityBreakdown: Record<string, number>;
|
||||||
|
/** Which rules contributed: [{ rule: "RULE_1", bonus: 30, name: "..." }, ...] */
|
||||||
|
ruleBreakdown: Array<{ rule: string; bonus: number; name: string }>;
|
||||||
|
/** Narrative summaries from matched rules */
|
||||||
|
narratives: string[];
|
||||||
|
/** Proactive recommendations */
|
||||||
|
recommendations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the unified threat score for a user.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Base score: sum of (severity_weight * time_decay) for each alert
|
||||||
|
* 2. Correlation bonus: +10-50 per matched rule
|
||||||
|
* 3. Total = base_score + correlation_bonus
|
||||||
|
* 4. Cap at 100, floor at 0
|
||||||
|
*
|
||||||
|
* @param alerts - All alerts within the 30-day window
|
||||||
|
* @returns Threat score result with full breakdown
|
||||||
|
*/
|
||||||
|
export function calculateThreatScore(alerts: AlertContext[]): ThreatScoreResult {
|
||||||
|
// Step 1: Calculate base score from individual alerts
|
||||||
|
const baseResult = calculateBaseScore(alerts);
|
||||||
|
const baseScore = Math.round(baseResult.total);
|
||||||
|
|
||||||
|
// Step 2: Run correlation rules and get bonus
|
||||||
|
const correlationResult = runCorrelationRules(alerts);
|
||||||
|
const correlationBonus = Math.round(correlationResult.totalBonus);
|
||||||
|
|
||||||
|
// Step 3: Calculate total score (capped at 100)
|
||||||
|
const rawScore = baseScore + correlationBonus;
|
||||||
|
const score = Math.max(0, Math.min(100, rawScore));
|
||||||
|
|
||||||
|
// Step 4: Build rule breakdown
|
||||||
|
const ruleBreakdown = correlationResult.matchedRules.map(r => ({
|
||||||
|
rule: r.id,
|
||||||
|
bonus: r.scoreBonus,
|
||||||
|
name: r.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
baseScore,
|
||||||
|
correlationBonus,
|
||||||
|
alertCount: alerts.length,
|
||||||
|
correlationCount: correlationResult.matchedRules.length,
|
||||||
|
sourceBreakdown: roundBreakdown(baseResult.bySource),
|
||||||
|
severityBreakdown: roundBreakdown(baseResult.bySeverity),
|
||||||
|
ruleBreakdown,
|
||||||
|
narratives: correlationResult.narratives,
|
||||||
|
recommendations: correlationResult.recommendations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate family-aggregated threat score.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* - highest individual score + average of others / 2
|
||||||
|
* - Cap at 100, floor at 0
|
||||||
|
*
|
||||||
|
* @param memberScores - Threat scores for each family member
|
||||||
|
* @returns Aggregated family threat score
|
||||||
|
*/
|
||||||
|
export function calculateFamilyThreatScore(
|
||||||
|
memberScores: Array<{ userId: string; score: number }>,
|
||||||
|
): number {
|
||||||
|
if (memberScores.length === 0) return 0;
|
||||||
|
if (memberScores.length === 1) return memberScores[0].score;
|
||||||
|
|
||||||
|
const sorted = [...memberScores].sort((a, b) => b.score - a.score);
|
||||||
|
const highest = sorted[0].score;
|
||||||
|
const others = sorted.slice(1);
|
||||||
|
const avgOthers = others.reduce((sum, m) => sum + m.score, 0) / others.length;
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, Math.round(highest + avgOthers / 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round all values in a breakdown object to 1 decimal place.
|
||||||
|
*/
|
||||||
|
function roundBreakdown(breakdown: Record<string, number>): Record<string, number> {
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
for (const [key, value] of Object.entries(breakdown)) {
|
||||||
|
result[key] = Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate proactive recommendations based on the current threat score
|
||||||
|
* and active correlations.
|
||||||
|
*
|
||||||
|
* @param score - Current threat score (0-100)
|
||||||
|
* @param narratives - Active correlation narratives
|
||||||
|
* @param recommendations - Rule-specific recommendations
|
||||||
|
* @returns Prioritized list of recommendations
|
||||||
|
*/
|
||||||
|
export function generateRecommendations(
|
||||||
|
score: number,
|
||||||
|
narratives: string[],
|
||||||
|
recommendations: string[],
|
||||||
|
): Array<{ priority: "critical" | "high" | "medium" | "low"; text: string }> {
|
||||||
|
const result: Array<{ priority: "critical" | "high" | "medium" | "low"; text: string }> = [];
|
||||||
|
|
||||||
|
// Score-based general recommendations
|
||||||
|
if (score > 60) {
|
||||||
|
result.push({ priority: "critical", text: "Your threat score is critically high. Take immediate action to protect your identity." });
|
||||||
|
result.push({ priority: "high", text: "Change passwords on all critical accounts immediately." });
|
||||||
|
result.push({ priority: "high", text: "Consider placing a credit freeze with all three credit bureaus." });
|
||||||
|
result.push({ priority: "medium", text: "Notify family members about the elevated threat level." });
|
||||||
|
} else if (score > 30) {
|
||||||
|
result.push({ priority: "high", text: "Your threat score is elevated. Review your active alerts and take preventive measures." });
|
||||||
|
result.push({ priority: "medium", text: "Enable two-factor authentication on accounts you haven't secured yet." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correlation-specific recommendations
|
||||||
|
const hasHomeTitleBroker = narratives.some(n =>
|
||||||
|
n.toLowerCase().includes("property") && n.toLowerCase().includes("broker")
|
||||||
|
);
|
||||||
|
if (hasHomeTitleBroker) {
|
||||||
|
result.push({ priority: "high", text: "Contact your title insurance provider for a review of recent property changes." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasVoiceClone = narratives.some(n =>
|
||||||
|
n.toLowerCase().includes("voice clone") || n.toLowerCase().includes("synthetic voice")
|
||||||
|
);
|
||||||
|
if (hasVoiceClone) {
|
||||||
|
result.push({ priority: "critical", text: "Warn all family members about potential voice-based scams targeting you." });
|
||||||
|
result.push({ priority: "high", text: "File an FTC identity theft report at IdentityTheft.gov." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rule-specific recommendations (deduplicated, lower priority)
|
||||||
|
for (const rec of recommendations) {
|
||||||
|
if (!result.some(r => r.text === rec)) {
|
||||||
|
result.push({ priority: "medium", text: rec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the threat level color category.
|
||||||
|
*/
|
||||||
|
export function getThreatLevel(score: number): {
|
||||||
|
level: "low" | "medium" | "high" | "critical";
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
} {
|
||||||
|
if (score <= 30) {
|
||||||
|
return { level: "low", color: "green", label: "Low Risk" };
|
||||||
|
}
|
||||||
|
if (score <= 60) {
|
||||||
|
return { level: "medium", color: "yellow", label: "Medium Risk" };
|
||||||
|
}
|
||||||
|
if (score <= 80) {
|
||||||
|
return { level: "high", color: "orange", label: "High Risk" };
|
||||||
|
}
|
||||||
|
return { level: "critical", color: "red", label: "Critical Risk" };
|
||||||
|
}
|
||||||
@@ -2,22 +2,28 @@ import { createHash } from "node:crypto";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq, and, desc, count, gte, lte, inArray, sql } from "drizzle-orm";
|
import { eq, and, desc, count, gte, lte, inArray, sql } from "drizzle-orm";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { watchlistItems, exposures, subscriptions, securityReports } from "~/server/db/schema";
|
import { watchlistItems, exposures, subscriptions, securityReports, scanHistory, scanQueue } from "~/server/db/schema";
|
||||||
import { scanHIBP, scanSecurityTrails, scanCensys, scanShodan, scanForums } from "./darkwatch/scan.engine";
|
import { scanHIBP, scanSecurityTrails, scanCensys, scanShodan, scanForums, type ScanOptions, type ScanResult } from "./darkwatch/scan.engine";
|
||||||
import { processExposure } from "./darkwatch/alert.pipeline";
|
import { processExposure } from "./darkwatch/alert.pipeline";
|
||||||
import type { ScanResult } from "./darkwatch/scan.engine";
|
|
||||||
import {
|
import {
|
||||||
getEffectiveTier,
|
getEffectiveTier,
|
||||||
getActiveTrials,
|
getActiveTrials,
|
||||||
type SubWithEffectiveTier,
|
type SubWithEffectiveTier,
|
||||||
} from "~/server/lib/tier";
|
} from "~/server/lib/tier";
|
||||||
|
import { broadcastScanEvent, type ScanStartedEvent, type ScanProgressEvent, type ScanCompletedEvent, type ScanFailedEvent, type ScanQueueEvent } from "~/server/websocket";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scan state tracking (in-memory, backed by scanHistory for persistence)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ScanState {
|
interface ScanState {
|
||||||
status: "idle" | "running" | "completed" | "failed";
|
status: "idle" | "running" | "completed" | "failed" | "queued";
|
||||||
|
scanId: string | null;
|
||||||
startedAt: Date | null;
|
startedAt: Date | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
totalSources: number;
|
totalSources: number;
|
||||||
completedSources: number;
|
completedSources: number;
|
||||||
|
currentSource: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +33,10 @@ function hashValue(value: string): string {
|
|||||||
return createHash("sha256").update(value.toLowerCase().trim()).digest("hex");
|
return createHash("sha256").update(value.toLowerCase().trim()).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Subscription helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getSubscription(userId: string): Promise<SubWithEffectiveTier> {
|
async function getSubscription(userId: string): Promise<SubWithEffectiveTier> {
|
||||||
const [sub] = await db
|
const [sub] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -51,6 +61,10 @@ async function getSubscription(userId: string): Promise<SubWithEffectiveTier> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watchlist CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getWatchlistItems(userId: string) {
|
export async function getWatchlistItems(userId: string) {
|
||||||
const sub = await getSubscription(userId);
|
const sub = await getSubscription(userId);
|
||||||
const items = await db
|
const items = await db
|
||||||
@@ -116,11 +130,15 @@ export async function removeWatchlistItem(userId: string, itemId: string) {
|
|||||||
const [deleted] = await db
|
const [deleted] = await db
|
||||||
.update(watchlistItems)
|
.update(watchlistItems)
|
||||||
.set({ isActive: false, updatedAt: new Date() })
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
.where(eq(watchlistItems.id, itemId))
|
.where(eq(watchlistItems.id, item.id))
|
||||||
.returning();
|
.returning();
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exposures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getExposures(
|
export async function getExposures(
|
||||||
userId: string,
|
userId: string,
|
||||||
filters?: {
|
filters?: {
|
||||||
@@ -189,6 +207,10 @@ export async function getExposureDetails(userId: string, exposureId: string) {
|
|||||||
return { ...exposure, watchlistItem: null };
|
return { ...exposure, watchlistItem: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tier limits
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function checkTierLimits(userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
export async function checkTierLimits(userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
||||||
const sub = await getSubscription(userId);
|
const sub = await getSubscription(userId);
|
||||||
const tier = sub.effectiveTier;
|
const tier = sub.effectiveTier;
|
||||||
@@ -228,7 +250,64 @@ export async function checkTierLimits(userId: string): Promise<{ allowed: boolea
|
|||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScan(userId: string): Promise<{ scanId: string }> {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Threat score calculation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a threat score (0-100) based on current exposures.
|
||||||
|
* Higher score = more risk.
|
||||||
|
*/
|
||||||
|
export async function calculateThreatScore(subscriptionId: string): Promise<number> {
|
||||||
|
const allExposures = await db
|
||||||
|
.select()
|
||||||
|
.from(exposures)
|
||||||
|
.where(eq(exposures.subscriptionId, subscriptionId));
|
||||||
|
|
||||||
|
if (!allExposures.length) return 0;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Base score from exposure count (diminishing returns)
|
||||||
|
const exposureCountScore = Math.min(30, Math.log2(allExposures.length + 1) * 10);
|
||||||
|
score += exposureCountScore;
|
||||||
|
|
||||||
|
// Severity weighting
|
||||||
|
for (const exp of allExposures) {
|
||||||
|
switch (exp.severity) {
|
||||||
|
case "critical":
|
||||||
|
score += 15;
|
||||||
|
break;
|
||||||
|
case "warning":
|
||||||
|
score += 8;
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
score += 3;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recency bonus — exposures found in last 7 days count more
|
||||||
|
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const recentExposures = allExposures.filter((e) => e.detectedAt >= weekAgo);
|
||||||
|
if (recentExposures.length > 0) {
|
||||||
|
score += Math.min(15, recentExposures.length * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-time exposures count more (new threat)
|
||||||
|
const newExposures = allExposures.filter((e) => e.isFirstTime);
|
||||||
|
if (newExposures.length > 0) {
|
||||||
|
score += Math.min(10, newExposures.length * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, Math.round(score));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scan execution with WebSocket progress, history, and failure recovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runScan(userId: string): Promise<{ scanId: string; queued: boolean }> {
|
||||||
const sub = await getSubscription(userId);
|
const sub = await getSubscription(userId);
|
||||||
|
|
||||||
const tierCheck = await checkTierLimits(userId);
|
const tierCheck = await checkTierLimits(userId);
|
||||||
@@ -236,26 +315,84 @@ export async function runScan(userId: string): Promise<{ scanId: string }> {
|
|||||||
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: tierCheck.reason });
|
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: tierCheck.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scanStates.get(userId)?.status === "running") {
|
// Check if scan is already running for this user
|
||||||
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Scan already in progress" });
|
const currentState = scanStates.get(userId);
|
||||||
|
if (currentState?.status === "running") {
|
||||||
|
// Queue the request
|
||||||
|
const queuedScanId = crypto.randomUUID();
|
||||||
|
const [queued] = await db
|
||||||
|
.insert(scanQueue)
|
||||||
|
.values({
|
||||||
|
subscriptionId: sub.id,
|
||||||
|
userId,
|
||||||
|
position: await getNextQueuePosition(sub.id),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
scanStates.set(userId, {
|
||||||
|
...currentState,
|
||||||
|
status: "queued",
|
||||||
|
scanId: queuedScanId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify via WebSocket
|
||||||
|
broadcastScanEvent(userId, {
|
||||||
|
type: "scan:queued",
|
||||||
|
scanId: queuedScanId,
|
||||||
|
position: queued.position,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scanId: queuedScanId, queued: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const scanId = crypto.randomUUID();
|
const scanId = crypto.randomUUID();
|
||||||
scanStates.set(userId, {
|
|
||||||
status: "running",
|
|
||||||
startedAt: new Date(),
|
|
||||||
completedAt: null,
|
|
||||||
totalSources: 4,
|
|
||||||
completedSources: 0,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = await db
|
const items = await db
|
||||||
.select()
|
.select()
|
||||||
.from(watchlistItems)
|
.from(watchlistItems)
|
||||||
.where(and(eq(watchlistItems.subscriptionId, sub.id), eq(watchlistItems.isActive, true)));
|
.where(and(eq(watchlistItems.subscriptionId, sub.id), eq(watchlistItems.isActive, true)));
|
||||||
|
|
||||||
processScan(userId, sub.id, items).catch((err) => {
|
if (items.length === 0) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "No active watchlist items to scan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total sources based on tier
|
||||||
|
const totalSources = calculateTotalSources(items, sub.effectiveTier);
|
||||||
|
|
||||||
|
// Initialize scan state
|
||||||
|
scanStates.set(userId, {
|
||||||
|
status: "running",
|
||||||
|
scanId,
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
|
totalSources,
|
||||||
|
completedSources: 0,
|
||||||
|
currentSource: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create scan history record
|
||||||
|
const [history] = await db
|
||||||
|
.insert(scanHistory)
|
||||||
|
.values({
|
||||||
|
subscriptionId: sub.id,
|
||||||
|
scanId,
|
||||||
|
status: "running",
|
||||||
|
totalSources,
|
||||||
|
startedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Emit scan:started event
|
||||||
|
broadcastScanEvent(userId, {
|
||||||
|
type: "scan:started",
|
||||||
|
scanId,
|
||||||
|
totalSources,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run scan asynchronously
|
||||||
|
processScan(userId, sub.id, sub.effectiveTier, items, history.id).catch((err) => {
|
||||||
console.error("[darkwatch] Scan failed:", err);
|
console.error("[darkwatch] Scan failed:", err);
|
||||||
const state = scanStates.get(userId);
|
const state = scanStates.get(userId);
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -264,54 +401,171 @@ export async function runScan(userId: string): Promise<{ scanId: string }> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { scanId };
|
return { scanId, queued: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotalSources(
|
||||||
|
items: Array<{ type: string }>,
|
||||||
|
tier: string,
|
||||||
|
): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case "email":
|
||||||
|
total += 1; // HIBP
|
||||||
|
break;
|
||||||
|
case "domain":
|
||||||
|
total += tier === "basic" ? 1 : 4; // HIBP + SecurityTrails + Censys + Shodan
|
||||||
|
break;
|
||||||
|
case "phoneNumber":
|
||||||
|
total += tier === "basic" ? 0 : 2; // Shodan + Censys
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
total += tier === "basic" ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(1, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextQueuePosition(subscriptionId: string): Promise<number> {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(scanQueue)
|
||||||
|
.where(eq(scanQueue.subscriptionId, subscriptionId));
|
||||||
|
return existing.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processScan(
|
async function processScan(
|
||||||
userId: string,
|
userId: string,
|
||||||
subscriptionId: string,
|
subscriptionId: string,
|
||||||
|
tier: string,
|
||||||
items: Array<{ id: string; type: string; value: string }>,
|
items: Array<{ id: string; type: string; value: string }>,
|
||||||
|
scanHistoryId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const allResults: ScanResult[] = [];
|
const scanOptions: ScanOptions = {
|
||||||
|
subscriptionId,
|
||||||
|
tier: tier as "basic" | "plus" | "premium",
|
||||||
|
};
|
||||||
|
|
||||||
|
const allResults: ScanResult[] = [];
|
||||||
|
const failedSources: string[] = [];
|
||||||
|
let exposuresFound = 0;
|
||||||
|
let alertsGenerated = 0;
|
||||||
|
let alertsSuppressed = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
let completedSources = 0;
|
||||||
|
const totalSources = calculateTotalSources(items, tier);
|
||||||
|
|
||||||
|
try {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const sourcePromises: Promise<ScanResult[]>[] = [];
|
const sourcePromises: Array<{ name: string; promise: Promise<ScanResult[]> }> = [];
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "email":
|
case "email":
|
||||||
sourcePromises.push(scanHIBP(item.value));
|
sourcePromises.push({
|
||||||
|
name: "hibp",
|
||||||
|
promise: scanHIBP(item.value, scanOptions),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "domain":
|
case "domain":
|
||||||
sourcePromises.push(scanSecurityTrails(item.value));
|
sourcePromises.push({
|
||||||
sourcePromises.push(scanCensys(item.value));
|
name: "hibp",
|
||||||
sourcePromises.push(scanShodan(item.value));
|
promise: scanHIBP(item.value, scanOptions),
|
||||||
|
});
|
||||||
|
if (tier !== "basic") {
|
||||||
|
const domain = item.value.includes("@") ? item.value.split("@")[1] : item.value;
|
||||||
|
sourcePromises.push({
|
||||||
|
name: "securityTrails",
|
||||||
|
promise: scanSecurityTrails(domain, scanOptions),
|
||||||
|
});
|
||||||
|
sourcePromises.push({
|
||||||
|
name: "censys",
|
||||||
|
promise: scanCensys(domain, scanOptions),
|
||||||
|
});
|
||||||
|
sourcePromises.push({
|
||||||
|
name: "shodan",
|
||||||
|
promise: scanShodan(domain, scanOptions),
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "phoneNumber":
|
case "phoneNumber":
|
||||||
sourcePromises.push(scanShodan(item.value));
|
if (tier !== "basic") {
|
||||||
sourcePromises.push(scanCensys(item.value));
|
sourcePromises.push({
|
||||||
|
name: "shodan",
|
||||||
|
promise: scanShodan(item.value, scanOptions),
|
||||||
|
});
|
||||||
|
sourcePromises.push({
|
||||||
|
name: "censys",
|
||||||
|
promise: scanCensys(item.value, scanOptions),
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
sourcePromises.push(scanShodan(item.value));
|
if (tier !== "basic") {
|
||||||
|
sourcePromises.push({
|
||||||
|
name: "shodan",
|
||||||
|
promise: scanShodan(item.value, scanOptions),
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sourcePromises.push(scanForums(item.value));
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(sourcePromises);
|
sourcePromises.push({
|
||||||
|
name: "forums",
|
||||||
|
promise: scanForums(item.value, scanOptions),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run sources with individual error handling (failure recovery)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
sourcePromises.map(({ name, promise }) =>
|
||||||
|
promise.then((r) => ({ name, results: r })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.status === "fulfilled") {
|
if (r.status === "fulfilled") {
|
||||||
allResults.push(...r.value.map((sr) => ({ ...sr, watchlistItemId: item.id })));
|
allResults.push(...r.value.results.map((sr) => ({ ...sr, watchlistItemId: item.id })));
|
||||||
|
} else {
|
||||||
|
// Individual source failure — record it but continue
|
||||||
|
console.error(`[darkwatch] Source failed:`, r.reason);
|
||||||
|
failedSources.push(r.reason instanceof Error ? r.reason.message : String(r.reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
// Update scan state and emit progress
|
||||||
const state = scanStates.get(userId);
|
const state = scanStates.get(userId);
|
||||||
if (state) {
|
if (state) {
|
||||||
state.completedSources++;
|
state.completedSources = completedSources;
|
||||||
}
|
state.currentSource = null;
|
||||||
|
|
||||||
|
const percentage = Math.round((completedSources / totalSources) * 100);
|
||||||
|
|
||||||
|
broadcastScanEvent(userId, {
|
||||||
|
type: "scan:progress",
|
||||||
|
scanId: state.scanId ?? "",
|
||||||
|
completedSources,
|
||||||
|
totalSources,
|
||||||
|
percentage,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update scan history in DB
|
||||||
|
await db
|
||||||
|
.update(scanHistory)
|
||||||
|
.set({
|
||||||
|
sourcesChecked: completedSources,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(scanHistory.id, scanHistoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all exposures through the alert pipeline (with dedup)
|
||||||
for (const result of allResults) {
|
for (const result of allResults) {
|
||||||
try {
|
try {
|
||||||
await processExposure({
|
const exposureResult = await processExposure({
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
watchlistItemId: (result as ScanResult & { watchlistItemId: string }).watchlistItemId,
|
watchlistItemId: (result as ScanResult & { watchlistItemId: string }).watchlistItemId,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
@@ -322,18 +576,138 @@ async function processScan(
|
|||||||
metadata: result.metadata,
|
metadata: result.metadata,
|
||||||
detectedAt: result.detectedAt,
|
detectedAt: result.detectedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exposuresFound++;
|
||||||
|
if (exposureResult.alertCreated) {
|
||||||
|
alertsGenerated++;
|
||||||
|
}
|
||||||
|
if (exposureResult.alertSuppressed) {
|
||||||
|
alertsSuppressed++;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[darkwatch] Failed to process exposure:", err);
|
console.error("[darkwatch] Failed to process exposure:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate threat score
|
||||||
|
const threatScore = await calculateThreatScore(subscriptionId);
|
||||||
|
|
||||||
|
// Determine new exposures count
|
||||||
|
const newExposures = allResults.filter((r) => r.severity !== "info").length;
|
||||||
|
|
||||||
|
// Finalize scan history
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
await db
|
||||||
|
.update(scanHistory)
|
||||||
|
.set({
|
||||||
|
status: "completed",
|
||||||
|
sourcesChecked: completedSources,
|
||||||
|
exposuresFound,
|
||||||
|
newExposures,
|
||||||
|
alertsGenerated,
|
||||||
|
alertsSuppressed,
|
||||||
|
durationMs,
|
||||||
|
failedSources: failedSources.length > 0 ? failedSources : null,
|
||||||
|
threatScore,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(scanHistory.id, scanHistoryId));
|
||||||
|
|
||||||
|
// Update in-memory state
|
||||||
const state = scanStates.get(userId);
|
const state = scanStates.get(userId);
|
||||||
if (state) {
|
if (state) {
|
||||||
state.status = "completed";
|
state.status = "completed";
|
||||||
state.completedAt = new Date();
|
state.completedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit scan:completed event
|
||||||
|
broadcastScanEvent(userId, {
|
||||||
|
type: "scan:completed",
|
||||||
|
scanId: state?.scanId ?? "",
|
||||||
|
exposuresFound,
|
||||||
|
newExposures,
|
||||||
|
alertsGenerated,
|
||||||
|
alertsSuppressed,
|
||||||
|
durationMs,
|
||||||
|
threatScore,
|
||||||
|
failedSources: failedSources.length > 0 ? failedSources : undefined,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[darkwatch] Scan completed for user ${userId}: ${exposuresFound} exposures, ${alertsGenerated} alerts, ${alertsSuppressed} suppressed, score: ${threatScore}`);
|
||||||
|
|
||||||
|
// Process any queued scans for this user
|
||||||
|
await processNextQueuedScan(userId, subscriptionId);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
// Entire scan failed — record it
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
await db
|
||||||
|
.update(scanHistory)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
sourcesChecked: completedSources,
|
||||||
|
failedSources: [...failedSources, err instanceof Error ? err.message : String(err)],
|
||||||
|
durationMs,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(scanHistory.id, scanHistoryId));
|
||||||
|
|
||||||
|
const state = scanStates.get(userId);
|
||||||
|
if (state) {
|
||||||
|
state.status = "failed";
|
||||||
|
state.error = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
state.completedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit scan:failed event
|
||||||
|
broadcastScanEvent(userId, {
|
||||||
|
type: "scan:failed",
|
||||||
|
scanId: state?.scanId ?? "",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still try to process queued scans
|
||||||
|
await processNextQueuedScan(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the next queued scan for a user after the current scan completes.
|
||||||
|
*/
|
||||||
|
async function processNextQueuedScan(userId: string, subscriptionId: string): Promise<void> {
|
||||||
|
const next = await db
|
||||||
|
.select()
|
||||||
|
.from(scanQueue)
|
||||||
|
.where(eq(scanQueue.subscriptionId, subscriptionId))
|
||||||
|
.orderBy(scanQueue.position)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (next.length > 0) {
|
||||||
|
// Remove from queue and start scan
|
||||||
|
await db
|
||||||
|
.delete(scanQueue)
|
||||||
|
.where(eq(scanQueue.id, next[0].id));
|
||||||
|
|
||||||
|
// Reset state and run scan
|
||||||
|
scanStates.delete(userId);
|
||||||
|
|
||||||
|
// Small delay to allow WebSocket events to settle
|
||||||
|
setTimeout(() => {
|
||||||
|
runScan(userId).catch((err) => {
|
||||||
|
console.error("[darkwatch] Queued scan failed:", err);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scan status and history
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function getScanStatus(userId: string) {
|
export async function getScanStatus(userId: string) {
|
||||||
const state = scanStates.get(userId);
|
const state = scanStates.get(userId);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -341,13 +715,46 @@ export async function getScanStatus(userId: string) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: state.status,
|
status: state.status,
|
||||||
|
scanId: state.scanId,
|
||||||
startedAt: state.startedAt,
|
startedAt: state.startedAt,
|
||||||
completedAt: state.completedAt,
|
completedAt: state.completedAt,
|
||||||
progress: state.totalSources > 0 ? state.completedSources / state.totalSources : 0,
|
progress: state.totalSources > 0 ? state.completedSources / state.totalSources : 0,
|
||||||
|
currentSource: state.currentSource,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getScanHistory(
|
||||||
|
userId: string,
|
||||||
|
filters?: { page?: number; limit?: number },
|
||||||
|
) {
|
||||||
|
const sub = await getSubscription(userId);
|
||||||
|
const page = filters?.page ?? 1;
|
||||||
|
const limit = filters?.limit ?? 20;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(scanHistory)
|
||||||
|
.where(eq(scanHistory.subscriptionId, sub.id));
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(scanHistory)
|
||||||
|
.where(eq(scanHistory.subscriptionId, sub.id))
|
||||||
|
.orderBy(desc(scanHistory.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: totalResult.count,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalResult.count / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getReports(
|
export async function getReports(
|
||||||
userId: string,
|
userId: string,
|
||||||
filters?: { page?: number; limit?: number },
|
filters?: { page?: number; limit?: number },
|
||||||
@@ -378,3 +785,15 @@ export async function getReports(
|
|||||||
totalPages: Math.ceil(totalResult.count / limit),
|
totalPages: Math.ceil(totalResult.count / limit),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function resetScanStates(): void {
|
||||||
|
scanStates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScanStateMap(): Map<string, ScanState> {
|
||||||
|
return scanStates;
|
||||||
|
}
|
||||||
|
|||||||
141
web/src/server/services/darkwatch/alert.cooldown.test.ts
Normal file
141
web/src/server/services/darkwatch/alert.cooldown.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
function makeChain(result: any[]) {
|
||||||
|
const chain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
then: vi.fn().mockImplementation((fn: Function) => Promise.resolve(fn(result))),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("~/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn().mockReturnValue(makeChain([])),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
}),
|
||||||
|
delete: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...(actual as any),
|
||||||
|
eq: vi.fn(),
|
||||||
|
and: vi.fn(),
|
||||||
|
gte: vi.fn(),
|
||||||
|
lte: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { checkAlertCooldown, recordAlertSent, ALERT_COOLDOWN_HOURS } from "./alert.cooldown";
|
||||||
|
|
||||||
|
const mockDb = vi.mocked(db);
|
||||||
|
|
||||||
|
describe("alert.cooldown", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkAlertCooldown", () => {
|
||||||
|
it("should allow alert when no cooldown record exists", async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeChain([]));
|
||||||
|
|
||||||
|
const result = await checkAlertCooldown("user-1", "exposure_detected", "hibp", "warning");
|
||||||
|
|
||||||
|
expect(result.shouldAlert).toBe(true);
|
||||||
|
expect(result.cooldownHours).toBe(ALERT_COOLDOWN_HOURS.exposure_detected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow alert when cooldown has expired", async () => {
|
||||||
|
const expiredTime = new Date(Date.now() - 48 * 60 * 60 * 1000); // 48h ago
|
||||||
|
mockDb.select.mockReturnValue(makeChain([{
|
||||||
|
lastAlertSentAt: expiredTime,
|
||||||
|
lastSeverity: "info",
|
||||||
|
cooldownHours: 24,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const result = await checkAlertCooldown("user-1", "exposure_detected", "hibp", "warning");
|
||||||
|
|
||||||
|
expect(result.shouldAlert).toBe(true);
|
||||||
|
expect(result.reason).toBe("cooldown_expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should suppress alert when within cooldown and same severity", async () => {
|
||||||
|
const recentTime = new Date(Date.now() - 5 * 60 * 60 * 1000); // 5h ago
|
||||||
|
mockDb.select.mockReturnValue(makeChain([{
|
||||||
|
lastAlertSentAt: recentTime,
|
||||||
|
lastSeverity: "warning",
|
||||||
|
cooldownHours: 24,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const result = await checkAlertCooldown("user-1", "exposure_detected", "hibp", "warning");
|
||||||
|
|
||||||
|
expect(result.shouldAlert).toBe(false);
|
||||||
|
expect(result.reason).toContain("within_cooldown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow alert when severity escalates within cooldown", async () => {
|
||||||
|
const recentTime = new Date(Date.now() - 5 * 60 * 60 * 1000); // 5h ago
|
||||||
|
mockDb.select.mockReturnValue(makeChain([{
|
||||||
|
lastAlertSentAt: recentTime,
|
||||||
|
lastSeverity: "warning",
|
||||||
|
cooldownHours: 24,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const result = await checkAlertCooldown("user-1", "exposure_detected", "hibp", "critical");
|
||||||
|
|
||||||
|
expect(result.shouldAlert).toBe(true);
|
||||||
|
expect(result.reason).toBe("severity_escalation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow alert for types with 0 cooldown", async () => {
|
||||||
|
const result = await checkAlertCooldown("user-1", "scan_completed", "hibp", "info");
|
||||||
|
|
||||||
|
expect(result.shouldAlert).toBe(true);
|
||||||
|
expect(result.cooldownHours).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use correct cooldown per alert type", () => {
|
||||||
|
expect(ALERT_COOLDOWN_HOURS.exposure_detected).toBe(24);
|
||||||
|
expect(ALERT_COOLDOWN_HOURS.property_change).toBe(72);
|
||||||
|
expect(ALERT_COOLDOWN_HOURS.new_breach).toBe(24);
|
||||||
|
expect(ALERT_COOLDOWN_HOURS.vulnerability_found).toBe(48);
|
||||||
|
expect(ALERT_COOLDOWN_HOURS.subdomain_discovery).toBe(168);
|
||||||
|
expect(ALERT_COOLDOWN_HOURS.scan_completed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordAlertSent", () => {
|
||||||
|
it("should create new cooldown record when none exists", async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeChain([]));
|
||||||
|
|
||||||
|
await recordAlertSent("user-1", "exposure_detected", "hibp", "warning", "exp-1");
|
||||||
|
|
||||||
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update existing cooldown record", async () => {
|
||||||
|
mockDb.select.mockReturnValue(makeChain([{
|
||||||
|
id: "cd-1",
|
||||||
|
lastAlertSentAt: new Date(),
|
||||||
|
lastSeverity: "info",
|
||||||
|
cooldownHours: 24,
|
||||||
|
}]));
|
||||||
|
|
||||||
|
await recordAlertSent("user-1", "exposure_detected", "hibp", "warning", "exp-1");
|
||||||
|
|
||||||
|
expect(mockDb.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
190
web/src/server/services/darkwatch/alert.cooldown.ts
Normal file
190
web/src/server/services/darkwatch/alert.cooldown.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { eq, and, gte, lte } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { alertCooldowns } from "~/server/db/schema";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cooldown configuration per alert type (in hours)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ALERT_COOLDOWN_HOURS: Record<string, number> = {
|
||||||
|
exposure_detected: 24, // Same breach/exposure: 24h cooldown
|
||||||
|
property_change: 72, // Property changes: 72h cooldown
|
||||||
|
new_breach: 24, // New breach: 24h cooldown
|
||||||
|
vulnerability_found: 48, // Vulnerability: 48h cooldown
|
||||||
|
subdomain_discovery: 168, // Subdomain: 7d cooldown (low urgency)
|
||||||
|
scan_completed: 0, // Scan completion: no cooldown
|
||||||
|
threat_score_change: 12, // Threat score change: 12h cooldown
|
||||||
|
digest_summary: 0, // Digest: no cooldown (batched)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default cooldown for unknown types
|
||||||
|
const DEFAULT_COOLDOWN_HOURS = 24;
|
||||||
|
|
||||||
|
const SEVERITY_ORDER: Record<string, number> = {
|
||||||
|
info: 0,
|
||||||
|
warning: 1,
|
||||||
|
critical: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CooldownResult {
|
||||||
|
shouldAlert: boolean;
|
||||||
|
reason?: string;
|
||||||
|
cooldownHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an alert should be sent based on cooldown rules.
|
||||||
|
* Returns shouldAlert=false if we're within the cooldown period
|
||||||
|
* and the new severity is not higher than the last alert.
|
||||||
|
*/
|
||||||
|
export async function checkAlertCooldown(
|
||||||
|
userId: string,
|
||||||
|
alertType: string,
|
||||||
|
source: string,
|
||||||
|
severity: "info" | "warning" | "critical",
|
||||||
|
exposureId?: string,
|
||||||
|
): Promise<CooldownResult> {
|
||||||
|
const cooldownHours = ALERT_COOLDOWN_HOURS[alertType] ?? DEFAULT_COOLDOWN_HOURS;
|
||||||
|
|
||||||
|
// No cooldown for types with 0 hours
|
||||||
|
if (cooldownHours === 0) {
|
||||||
|
return { shouldAlert: true, cooldownHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(alertCooldowns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(alertCooldowns.userId, userId),
|
||||||
|
eq(alertCooldowns.alertType, alertType),
|
||||||
|
eq(alertCooldowns.source, source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { shouldAlert: true, cooldownHours };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're within the cooldown window
|
||||||
|
const cooldownMs = cooldownHours * 60 * 60 * 1000;
|
||||||
|
const cooldownEnd = existing.lastAlertSentAt.getTime() + cooldownMs;
|
||||||
|
|
||||||
|
if (Date.now() >= cooldownEnd) {
|
||||||
|
// Cooldown expired — allow new alert
|
||||||
|
return { shouldAlert: true, reason: "cooldown_expired", cooldownHours };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new severity is higher than last alert
|
||||||
|
const lastSeverityLevel = SEVERITY_ORDER[existing.lastSeverity] ?? 0;
|
||||||
|
const newSeverityLevel = SEVERITY_ORDER[severity] ?? 0;
|
||||||
|
|
||||||
|
if (newSeverityLevel > lastSeverityLevel) {
|
||||||
|
return { shouldAlert: true, reason: "severity_escalation", cooldownHours };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldAlert: false,
|
||||||
|
reason: `within_cooldown (${cooldownHours}h, ${Math.ceil((cooldownEnd - Date.now()) / (60 * 60 * 1000))}h remaining)`,
|
||||||
|
cooldownHours,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an alert as sent, updating the cooldown record.
|
||||||
|
*/
|
||||||
|
export async function recordAlertSent(
|
||||||
|
userId: string,
|
||||||
|
alertType: string,
|
||||||
|
source: string,
|
||||||
|
severity: "info" | "warning" | "critical",
|
||||||
|
exposureId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const cooldownHours = ALERT_COOLDOWN_HOURS[alertType] ?? DEFAULT_COOLDOWN_HOURS;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(alertCooldowns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(alertCooldowns.userId, userId),
|
||||||
|
eq(alertCooldowns.alertType, alertType),
|
||||||
|
eq(alertCooldowns.source, source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(alertCooldowns)
|
||||||
|
.set({
|
||||||
|
lastAlertSentAt: new Date(),
|
||||||
|
lastSeverity: severity,
|
||||||
|
cooldownHours,
|
||||||
|
exposureId: exposureId ?? existing.exposureId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(alertCooldowns.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(alertCooldowns).values({
|
||||||
|
userId,
|
||||||
|
alertType,
|
||||||
|
source,
|
||||||
|
severity,
|
||||||
|
lastAlertSentAt: new Date(),
|
||||||
|
lastSeverity: severity,
|
||||||
|
cooldownHours,
|
||||||
|
exposureId: exposureId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up expired cooldown records (older than 30 days).
|
||||||
|
* Should be called periodically.
|
||||||
|
*/
|
||||||
|
export async function cleanupExpiredCooldowns(): Promise<number> {
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(alertCooldowns)
|
||||||
|
.where(lte(alertCooldowns.lastAlertSentAt, thirtyDaysAgo));
|
||||||
|
|
||||||
|
// SQLite drizzle delete doesn't always return count, so we log
|
||||||
|
console.log(`[alert.cooldown] Cleaned up cooldown records older than 30 days`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets cooldown status for a user (for dashboard display).
|
||||||
|
*/
|
||||||
|
export async function getUserCooldownStatus(userId: string): Promise<
|
||||||
|
Array<{
|
||||||
|
alertType: string;
|
||||||
|
source: string;
|
||||||
|
lastAlertSentAt: Date;
|
||||||
|
cooldownHours: number;
|
||||||
|
remainingHours: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const records = await db
|
||||||
|
.select()
|
||||||
|
.from(alertCooldowns)
|
||||||
|
.where(eq(alertCooldowns.userId, userId));
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
return records
|
||||||
|
.map((r) => {
|
||||||
|
const cooldownEnd = r.lastAlertSentAt.getTime() + r.cooldownHours * 60 * 60 * 1000;
|
||||||
|
const remaining = Math.max(0, cooldownEnd - now);
|
||||||
|
return {
|
||||||
|
alertType: r.alertType,
|
||||||
|
source: r.source,
|
||||||
|
lastAlertSentAt: r.lastAlertSentAt,
|
||||||
|
cooldownHours: r.cooldownHours,
|
||||||
|
remainingHours: Math.ceil(remaining / (60 * 60 * 1000)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.remainingHours > 0);
|
||||||
|
}
|
||||||
@@ -2,25 +2,124 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { exposures, alerts, subscriptions } from "~/server/db/schema";
|
import { exposures, alerts, subscriptions } from "~/server/db/schema";
|
||||||
import { publishAlert } from "~/server/services/alert.publisher";
|
import { publishAlert } from "~/server/services/alert.publisher";
|
||||||
|
import { calculateSeverityFromDataClasses } from "./hibp.client";
|
||||||
|
import { checkAlertCooldown, recordAlertSent } from "./alert.cooldown";
|
||||||
|
import { shouldDigest, queueForDigest } from "./digest.service";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Severity scoring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CRITICAL_TYPES = new Set(["ssn"]);
|
||||||
|
const WARNING_SOURCES = new Set(["shodan", "censys", "darkWebForum"]);
|
||||||
|
const WARNING_TYPES = new Set(["email", "phoneNumber"]);
|
||||||
|
|
||||||
|
// Exposure types that are always critical regardless of source
|
||||||
|
const CRITICAL_EXPOSURE_TYPES = new Set([
|
||||||
|
"open_database",
|
||||||
|
"default_credentials",
|
||||||
|
"admin_panel",
|
||||||
|
"domain_hijack_risk",
|
||||||
|
"vulnerable_service",
|
||||||
|
"tor_exit_node",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Exposure types that are always warning
|
||||||
|
const WARNING_EXPOSURE_TYPES = new Set([
|
||||||
|
"subdomain_exposure",
|
||||||
|
"dns_misconfiguration",
|
||||||
|
"exposed_service",
|
||||||
|
"certificate_issue",
|
||||||
|
"iot_exposure",
|
||||||
|
]);
|
||||||
|
|
||||||
export function severityScore(exposure: {
|
export function severityScore(exposure: {
|
||||||
source: string;
|
source: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
}): "info" | "warning" | "critical" {
|
}): "info" | "warning" | "critical" {
|
||||||
const criticalSources = new Set(["hibp"]);
|
// If the exposure carries HIBP data classes in metadata, score from those
|
||||||
const warningSources = new Set(["shodan", "censys", "darkWebForum"]);
|
if (
|
||||||
const criticalTypes = new Set(["ssn"]);
|
exposure.metadata?.dataClasses &&
|
||||||
const warningTypes = new Set(["email", "phoneNumber"]);
|
Array.isArray(exposure.metadata.dataClasses)
|
||||||
|
) {
|
||||||
|
return calculateSeverityFromDataClasses(
|
||||||
|
exposure.metadata.dataClasses as string[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (criticalSources.has(exposure.source) || criticalTypes.has(exposure.dataType)) {
|
// Check exposure type from metadata (SecurityTrails/Censys/Shodan)
|
||||||
|
const exposureType = exposure.metadata?.exposureType as string | undefined;
|
||||||
|
if (exposureType) {
|
||||||
|
if (CRITICAL_EXPOSURE_TYPES.has(exposureType)) return "critical";
|
||||||
|
if (WARNING_EXPOSURE_TYPES.has(exposureType)) return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data type heuristics
|
||||||
|
if (CRITICAL_TYPES.has(exposure.dataType)) {
|
||||||
return "critical";
|
return "critical";
|
||||||
}
|
}
|
||||||
if (warningSources.has(exposure.source) || warningTypes.has(exposure.dataType)) {
|
|
||||||
|
// Source heuristics (fallback)
|
||||||
|
if (
|
||||||
|
WARNING_SOURCES.has(exposure.source) ||
|
||||||
|
WARNING_TYPES.has(exposure.dataType)
|
||||||
|
) {
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "info";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert title generation based on exposure type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function generateAlertTitle(
|
||||||
|
severity: "info" | "warning" | "critical",
|
||||||
|
exposureType?: string,
|
||||||
|
): string {
|
||||||
|
if (!exposureType) {
|
||||||
|
return `${severity === "critical" ? "Critical" : severity === "warning" ? "Warning" : "Info"} exposure detected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMap: Record<string, string> = {
|
||||||
|
subdomain_discovery: "Subdomain discovered",
|
||||||
|
subdomain_exposure: "Large subdomain attack surface",
|
||||||
|
dns_misconfiguration: "DNS misconfiguration detected",
|
||||||
|
domain_hijack_risk: "Domain hijacking risk",
|
||||||
|
exposed_service: "Exposed service detected",
|
||||||
|
open_database: "Open database detected",
|
||||||
|
admin_panel: "Admin panel exposed",
|
||||||
|
default_credentials: "Default credentials risk",
|
||||||
|
certificate_issue: "Certificate issue detected",
|
||||||
|
iot_exposure: "IoT device exposure",
|
||||||
|
tor_exit_node: "Tor exit node detected",
|
||||||
|
vulnerable_service: "Vulnerable service detected",
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefix = severity === "critical" ? "Critical" : severity === "warning" ? "Warning" : "Info";
|
||||||
|
return `${prefix}: ${titleMap[exposureType] ?? exposureType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAlertMessage(
|
||||||
|
exposure: { dataType: string; source: string; identifier: string; metadata?: Record<string, unknown> | null },
|
||||||
|
): string {
|
||||||
|
const detail = exposure.metadata?.detail as string | undefined;
|
||||||
|
if (detail) return detail;
|
||||||
|
return `${exposure.dataType} exposed on ${exposure.source}: ${exposure.identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exposure processing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ProcessExposureResult {
|
||||||
|
exposureId: string;
|
||||||
|
alertCreated: boolean;
|
||||||
|
alertSuppressed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function processExposure(newExposure: {
|
export async function processExposure(newExposure: {
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
watchlistItemId?: string | null;
|
watchlistItemId?: string | null;
|
||||||
@@ -31,7 +130,7 @@ export async function processExposure(newExposure: {
|
|||||||
severity: string;
|
severity: string;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
detectedAt: Date;
|
detectedAt: Date;
|
||||||
}): Promise<{ exposureId: string; alertCreated: boolean }> {
|
}): Promise<ProcessExposureResult> {
|
||||||
const severity = newExposure.severity as "info" | "warning" | "critical";
|
const severity = newExposure.severity as "info" | "warning" | "critical";
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -49,7 +148,7 @@ export async function processExposure(newExposure: {
|
|||||||
const currentSeverityIdx = ["info", "warning", "critical"].indexOf(existing.severity);
|
const currentSeverityIdx = ["info", "warning", "critical"].indexOf(existing.severity);
|
||||||
const newSeverityIdx = ["info", "warning", "critical"].indexOf(severity);
|
const newSeverityIdx = ["info", "warning", "critical"].indexOf(severity);
|
||||||
if (newSeverityIdx <= currentSeverityIdx) {
|
if (newSeverityIdx <= currentSeverityIdx) {
|
||||||
return { exposureId: existing.id, alertCreated: false };
|
return { exposureId: existing.id, alertCreated: false, alertSuppressed: false };
|
||||||
}
|
}
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(exposures)
|
.update(exposures)
|
||||||
@@ -61,8 +160,8 @@ export async function processExposure(newExposure: {
|
|||||||
})
|
})
|
||||||
.where(eq(exposures.id, existing.id))
|
.where(eq(exposures.id, existing.id))
|
||||||
.returning();
|
.returning();
|
||||||
await createAlertForExposure(updated, severity);
|
const alertResult = await createAlertForExposureWithResult(updated, severity);
|
||||||
return { exposureId: updated.id, alertCreated: true };
|
return { exposureId: updated.id, alertCreated: alertResult.alertCreated, alertSuppressed: alertResult.alertSuppressed };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [inserted] = await db
|
const [inserted] = await db
|
||||||
@@ -81,22 +180,29 @@ export async function processExposure(newExposure: {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await createAlertForExposure(inserted, severity);
|
const alertResult = await createAlertForExposureWithResult(inserted, severity);
|
||||||
return { exposureId: inserted.id, alertCreated: true };
|
return { exposureId: inserted.id, alertCreated: alertResult.alertCreated, alertSuppressed: alertResult.alertSuppressed };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAlertForExposure(
|
async function createAlertForExposure(
|
||||||
exposure: { id: string; subscriptionId: string; severity: string; dataType: string; source: string; identifier: string },
|
exposure: { id: string; subscriptionId: string; severity: string; dataType: string; source: string; identifier: string; metadata?: unknown },
|
||||||
severity: "info" | "warning" | "critical",
|
severity: "info" | "warning" | "critical",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const alertSeverityMap: Record<string, "info" | "warning" | "critical"> = {
|
await createAlertForExposureWithResult(exposure, severity);
|
||||||
info: "info",
|
}
|
||||||
warning: "warning",
|
|
||||||
critical: "critical",
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = `${severity === "critical" ? "Critical" : severity === "warning" ? "Warning" : "Info"} exposure detected`;
|
async function createAlertForExposureWithResult(
|
||||||
const message = `${exposure.dataType} exposed on ${exposure.source}: ${exposure.identifier}`;
|
exposure: { id: string; subscriptionId: string; severity: string; dataType: string; source: string; identifier: string; metadata?: unknown },
|
||||||
|
severity: "info" | "warning" | "critical",
|
||||||
|
): Promise<{ alertCreated: boolean; alertSuppressed: boolean }> {
|
||||||
|
const exposureType = (exposure.metadata as Record<string, unknown> | undefined)?.exposureType as string | undefined;
|
||||||
|
const title = generateAlertTitle(severity, exposureType);
|
||||||
|
const message = generateAlertMessage({
|
||||||
|
dataType: exposure.dataType,
|
||||||
|
source: exposure.source,
|
||||||
|
identifier: exposure.identifier,
|
||||||
|
metadata: exposure.metadata as Record<string, unknown> | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const [sub] = await db
|
const [sub] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -104,7 +210,33 @@ async function createAlertForExposure(
|
|||||||
.where(eq(subscriptions.id, exposure.subscriptionId))
|
.where(eq(subscriptions.id, exposure.subscriptionId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!sub) return;
|
if (!sub) return { alertCreated: false, alertSuppressed: false };
|
||||||
|
|
||||||
|
const alertSeverityMap: Record<string, "info" | "warning" | "critical"> = {
|
||||||
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
critical: "critical",
|
||||||
|
};
|
||||||
|
|
||||||
|
const alertSeverity = alertSeverityMap[severity] ?? "info";
|
||||||
|
const alertType = "exposure_detected";
|
||||||
|
const source = exposure.source;
|
||||||
|
|
||||||
|
// Check cooldown — skip alert if within cooldown period
|
||||||
|
const cooldown = await checkAlertCooldown(
|
||||||
|
sub.userId,
|
||||||
|
alertType,
|
||||||
|
source,
|
||||||
|
alertSeverity,
|
||||||
|
exposure.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cooldown.shouldAlert) {
|
||||||
|
console.log(
|
||||||
|
`[darkwatch] Alert suppressed for user ${sub.userId}: ${alertType} from ${source} — ${cooldown.reason}`,
|
||||||
|
);
|
||||||
|
return { alertCreated: false, alertSuppressed: true };
|
||||||
|
}
|
||||||
|
|
||||||
const [alert] = await db
|
const [alert] = await db
|
||||||
.insert(alerts)
|
.insert(alerts)
|
||||||
@@ -112,21 +244,39 @@ async function createAlertForExposure(
|
|||||||
subscriptionId: exposure.subscriptionId,
|
subscriptionId: exposure.subscriptionId,
|
||||||
userId: sub.userId,
|
userId: sub.userId,
|
||||||
exposureId: exposure.id,
|
exposureId: exposure.id,
|
||||||
type: "exposure_detected",
|
type: alertType,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
severity: alertSeverityMap[severity] ?? "info",
|
severity: alertSeverity,
|
||||||
channel: ["email", "push"],
|
channel: ["email", "push"],
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Record that alert was sent (updates cooldown)
|
||||||
|
await recordAlertSent(
|
||||||
|
sub.userId,
|
||||||
|
alertType,
|
||||||
|
source,
|
||||||
|
alertSeverity,
|
||||||
|
exposure.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route: immediate for warning/critical, digest for info
|
||||||
|
const useDigest = await shouldDigest(sub.userId, alertSeverity);
|
||||||
|
|
||||||
|
if (useDigest) {
|
||||||
|
await queueForDigest(sub.userId, alert.id, title, alertSeverity, source);
|
||||||
|
} else {
|
||||||
publishAlert(sub.userId, {
|
publishAlert(sub.userId, {
|
||||||
id: alert.id,
|
id: alert.id,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
severity: alertSeverityMap[severity] ?? "info",
|
severity: alertSeverity,
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
category: "EXPOSURE_DETECTED",
|
category: "EXPOSURE_DETECTED",
|
||||||
createdAt: alert.createdAt,
|
createdAt: alert.createdAt,
|
||||||
}).catch((err) => console.error("[darkwatch] Failed to publish alert:", err));
|
}).catch((err) => console.error("[darkwatch] Failed to publish alert:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { alertCreated: true, alertSuppressed: false };
|
||||||
|
}
|
||||||
|
|||||||
466
web/src/server/services/darkwatch/censys.client.test.ts
Normal file
466
web/src/server/services/darkwatch/censys.client.test.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
CensysClient,
|
||||||
|
resetCensysClient,
|
||||||
|
getCensysClient,
|
||||||
|
} from "./censys.client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CensysClient — unit tests with mocked fetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("CensysClient", () => {
|
||||||
|
const apiId = "test-api-id";
|
||||||
|
const apiSecret = "test-api-secret";
|
||||||
|
let client: CensysClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCensysClient();
|
||||||
|
client = new CensysClient(apiId, apiSecret, 100); // high rate limit for tests
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Auth header
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("authentication", () => {
|
||||||
|
it("sends Basic auth with api_id:api_secret", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ result: { hosts: [] }, meta: { total: 0 } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.searchHosts("example.com");
|
||||||
|
|
||||||
|
const expectedAuth = `Basic ${Buffer.from(`${apiId}:${apiSecret}`).toString("base64")}`;
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("censys.io"),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expectedAuth,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 401 auth failure", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow(
|
||||||
|
"Censys authentication failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// searchHosts
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("searchHosts", () => {
|
||||||
|
it("returns parsed host search results", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
result: {
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
ip: "93.184.216.34",
|
||||||
|
services: [
|
||||||
|
{ port: 80, service_name: "HTTP", banner: "Apache/2.4" },
|
||||||
|
{ port: 443, service_name: "HTTPS" },
|
||||||
|
],
|
||||||
|
locations: { country: "US", country_code: "US", city: "Los Angeles" },
|
||||||
|
autonomous_system: { as_number: 15133, description: "EDGECAST" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta: { total: 1, page: 1, pages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.searchHosts("example.com");
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.hosts).toHaveLength(1);
|
||||||
|
expect(result.hosts[0].ip).toBe("93.184.216.34");
|
||||||
|
expect(result.hosts[0].services).toHaveLength(2);
|
||||||
|
expect(result.hosts[0].services[0].port).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty results", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ result: { hosts: [] }, meta: { total: 0 } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.searchHosts("nonexistent.example");
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.hosts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 429 rate limit", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 429 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow(
|
||||||
|
"Censys rate limit exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// viewHost
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("viewHost", () => {
|
||||||
|
it("returns detailed host info", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
result: {
|
||||||
|
ip: "93.184.216.34",
|
||||||
|
services: [
|
||||||
|
{ port: 80, service_name: "HTTP", banner: "Apache/2.4.41", transport_protocol: "TCP" },
|
||||||
|
{ port: 443, service_name: "HTTPS", product: "Apache", version: "2.4.41" },
|
||||||
|
],
|
||||||
|
locations: { country: "US", latitude: 34.05, longitude: -118.25 },
|
||||||
|
autonomous_system: { as_number: 15133, description: "EDGECAST" },
|
||||||
|
dns: { reverse_dns: { names: ["example.com"] } },
|
||||||
|
metadata: {
|
||||||
|
last_updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
Manny: { first_observation: "2020-01-01", last_observation: "2024-01-01" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.viewHost("93.184.216.34");
|
||||||
|
expect(result?.ip).toBe("93.184.216.34");
|
||||||
|
expect(result?.services).toHaveLength(2);
|
||||||
|
expect(result?.autonomous_system?.as_number).toBe(15133);
|
||||||
|
expect(result?.timestamps?.first_observation).toBe("2020-01-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on API error", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 404 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.viewHost("1.2.3.4");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getCertificates
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getCertificates", () => {
|
||||||
|
it("returns certificate search results", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
result: {
|
||||||
|
certificates: [
|
||||||
|
{
|
||||||
|
fingerprint_sha256: "abc123",
|
||||||
|
parse_date: "2024-01-01",
|
||||||
|
not_before: "2024-01-01T00:00:00Z",
|
||||||
|
not_after: "2025-01-01T00:00:00Z",
|
||||||
|
subject: { common_name: "example.com", organization: "Example Inc" },
|
||||||
|
issuer: { common_name: "Let's Encrypt Authority X3", organization: "Let's Encrypt" },
|
||||||
|
names: ["example.com", "www.example.com"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta: { total: 1, page: 1, pages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getCertificates("example.com");
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.certificates).toHaveLength(1);
|
||||||
|
expect(result.certificates[0].subject.common_name).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches by certificate.names field", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ result: { certificates: [] }, meta: { total: 0 } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.getCertificates("example.com");
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("certificate.names"),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeHostExposures
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("analyzeHostExposures", () => {
|
||||||
|
it("detects open database on MySQL port", () => {
|
||||||
|
const host = {
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
services: [{ port: 3306, banner: "MySQL 5.7" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const dbExp = exposures.find((e) => e.type === "open_database");
|
||||||
|
expect(dbExp).toBeDefined();
|
||||||
|
expect(dbExp?.severity).toBe("critical");
|
||||||
|
expect(dbExp?.detail).toContain("MySQL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects exposed RDP", () => {
|
||||||
|
const host = {
|
||||||
|
ip: "5.6.7.8",
|
||||||
|
services: [{ port: 3389, service_name: "RDP" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const rdpExp = exposures.find((e) => e.detail.includes("RDP"));
|
||||||
|
expect(rdpExp).toBeDefined();
|
||||||
|
expect(rdpExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects exposed Redis", () => {
|
||||||
|
const host = {
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
services: [{ port: 6379, banner: "Redis 6.0" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const redisExp = exposures.find((e) => e.type === "open_database");
|
||||||
|
expect(redisExp).toBeDefined();
|
||||||
|
expect(redisExp?.detail).toContain("Redis");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks HTTP/80 as info severity", () => {
|
||||||
|
const host = {
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
services: [{ port: 80, service_name: "HTTP" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const httpExp = exposures.find((e) => e.port === 80);
|
||||||
|
expect(httpExp?.severity).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no exposures for clean host", () => {
|
||||||
|
const host = {
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
services: [{ port: 12345, service_name: "Unknown" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
expect(exposures.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeCertificateExposures
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("analyzeCertificateExposures", () => {
|
||||||
|
it("detects expired certificate", () => {
|
||||||
|
const certs = [
|
||||||
|
{
|
||||||
|
fingerprint_sha256: "abc",
|
||||||
|
parse_date: "2023-01-01",
|
||||||
|
not_before: "2022-01-01T00:00:00Z",
|
||||||
|
not_after: "2023-06-01T00:00:00Z",
|
||||||
|
subject: { common_name: "expired.example.com" },
|
||||||
|
issuer: { common_name: "Test CA" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const exposures = client.analyzeCertificateExposures(certs);
|
||||||
|
const expCert = exposures.find((e) => e.type === "certificate_issue");
|
||||||
|
expect(expCert).toBeDefined();
|
||||||
|
expect(expCert?.severity).toBe("critical");
|
||||||
|
expect(expCert?.detail).toContain("expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects certificate expiring soon", () => {
|
||||||
|
const soon = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const certs = [
|
||||||
|
{
|
||||||
|
fingerprint_sha256: "def",
|
||||||
|
parse_date: "2024-01-01",
|
||||||
|
not_before: "2024-01-01T00:00:00Z",
|
||||||
|
not_after: soon,
|
||||||
|
subject: { common_name: "expiring.example.com" },
|
||||||
|
issuer: { common_name: "Let's Encrypt" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const exposures = client.analyzeCertificateExposures(certs);
|
||||||
|
const expCert = exposures.find((e) => e.type === "certificate_issue");
|
||||||
|
expect(expCert).toBeDefined();
|
||||||
|
expect(expCert?.severity).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects untrusted issuer", () => {
|
||||||
|
const certs = [
|
||||||
|
{
|
||||||
|
fingerprint_sha256: "ghi",
|
||||||
|
parse_date: "2024-01-01",
|
||||||
|
not_before: "2024-01-01T00:00:00Z",
|
||||||
|
not_after: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
subject: { common_name: "self-signed.example.com", organization: "Example" },
|
||||||
|
issuer: { common_name: "Self Signed", organization: "Nobody" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const exposures = client.analyzeCertificateExposures(certs);
|
||||||
|
const untrusted = exposures.find(
|
||||||
|
(e) => e.type === "certificate_issue" && e.detail.includes("Untrusted"),
|
||||||
|
);
|
||||||
|
expect(untrusted).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects known vulnerabilities", () => {
|
||||||
|
const certs = [
|
||||||
|
{
|
||||||
|
fingerprint_sha256: "jkl",
|
||||||
|
parse_date: "2024-01-01",
|
||||||
|
not_before: "2024-01-01T00:00:00Z",
|
||||||
|
not_after: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
subject: { common_name: "vuln.example.com" },
|
||||||
|
issuer: { common_name: "Let's Encrypt" },
|
||||||
|
vulnerabilities: ["CVE-2024-1234", "CVE-2024-5678"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const exposures = client.analyzeCertificateExposures(certs);
|
||||||
|
const vulnExp = exposures.find((e) => e.vulnerabilityIds?.length);
|
||||||
|
expect(vulnExp).toBeDefined();
|
||||||
|
expect(vulnExp?.severity).toBe("critical");
|
||||||
|
expect(vulnExp?.vulnerabilityIds).toContain("CVE-2024-1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no exposures for healthy certificate", () => {
|
||||||
|
const certs = [
|
||||||
|
{
|
||||||
|
fingerprint_sha256: "mno",
|
||||||
|
parse_date: "2024-01-01",
|
||||||
|
not_before: "2024-01-01T00:00:00Z",
|
||||||
|
not_after: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
subject: { common_name: "healthy.example.com" },
|
||||||
|
issuer: { common_name: "Let's Encrypt Authority X3", organization: "Let's Encrypt" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const exposures = client.analyzeCertificateExposures(certs);
|
||||||
|
expect(exposures.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Circuit breaker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("circuit breaker", () => {
|
||||||
|
it("opens after 3 consecutive failures", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow(
|
||||||
|
"Censys circuit breaker is open",
|
||||||
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets after successful call", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
// Fail once
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow();
|
||||||
|
|
||||||
|
// Succeed
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ result: { hosts: [] }, meta: { total: 0 } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await client.searchHosts("test.com");
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
|
||||||
|
// Circuit should be reset
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
await expect(client.searchHosts("test.com")).rejects.toThrow(
|
||||||
|
"Censys circuit breaker is open",
|
||||||
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Singleton
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("singleton", () => {
|
||||||
|
it("creates client from env vars", () => {
|
||||||
|
process.env.CENSYS_API_ID = "env-id";
|
||||||
|
process.env.CENSYS_API_SECRET = "env-secret";
|
||||||
|
resetCensysClient();
|
||||||
|
const c = getCensysClient();
|
||||||
|
expect(c).toBeInstanceOf(CensysClient);
|
||||||
|
delete process.env.CENSYS_API_ID;
|
||||||
|
delete process.env.CENSYS_API_SECRET;
|
||||||
|
resetCensysClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when env vars missing", () => {
|
||||||
|
delete process.env.CENSYS_API_ID;
|
||||||
|
delete process.env.CENSYS_API_SECRET;
|
||||||
|
resetCensysClient();
|
||||||
|
expect(() => getCensysClient()).toThrow("CENSYS_API_ID");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
496
web/src/server/services/darkwatch/censys.client.ts
Normal file
496
web/src/server/services/darkwatch/censys.client.ts
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import { createHash, createHmac } from "node:crypto";
|
||||||
|
import { get, set } from "~/server/lib/cache";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CensysService {
|
||||||
|
port: number;
|
||||||
|
banner?: string;
|
||||||
|
service_name?: string;
|
||||||
|
product?: string;
|
||||||
|
version?: string;
|
||||||
|
transport_protocol?: string;
|
||||||
|
extended_hardware_io_ports?: Array<{ port: number; protocol: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensysTLS {
|
||||||
|
cert_not_before?: string;
|
||||||
|
cert_not_after?: string;
|
||||||
|
ja3s?: string;
|
||||||
|
tls_version?: string;
|
||||||
|
cipher_suite?: { id?: number; name?: string };
|
||||||
|
cert_subject?: { cn?: string; o?: string };
|
||||||
|
cert_issuer?: { cn?: string; o?: string };
|
||||||
|
cert_fingerprint_sha256?: string;
|
||||||
|
supported_versions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensysHost {
|
||||||
|
ip: string;
|
||||||
|
services: CensysService[];
|
||||||
|
locations?: {
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
|
city?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
autonomous_system?: {
|
||||||
|
cc?: string;
|
||||||
|
country?: string;
|
||||||
|
description?: string;
|
||||||
|
as_number?: number;
|
||||||
|
path?: number[];
|
||||||
|
};
|
||||||
|
dns?: {
|
||||||
|
reverse_dns?: { names?: string[] };
|
||||||
|
records?: { cname?: string[]; a?: string[]; aaaa?: string[] };
|
||||||
|
};
|
||||||
|
last_updated_at?: string;
|
||||||
|
timestamps?: {
|
||||||
|
first_observation?: string;
|
||||||
|
last_observation?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensysCertificate {
|
||||||
|
fingerprint_sha256: string;
|
||||||
|
parse_date: string;
|
||||||
|
not_before: string;
|
||||||
|
not_after: string;
|
||||||
|
subject: {
|
||||||
|
cn?: string;
|
||||||
|
organization?: string;
|
||||||
|
common_name?: string;
|
||||||
|
};
|
||||||
|
issuer: {
|
||||||
|
cn?: string;
|
||||||
|
organization?: string;
|
||||||
|
common_name?: string;
|
||||||
|
};
|
||||||
|
names?: string[];
|
||||||
|
revocation_state?: string;
|
||||||
|
ocsp_revocation_state?: string;
|
||||||
|
crt_shoulder_of_lameness?: string;
|
||||||
|
ct_observations?: {
|
||||||
|
google_aurora?: { timestamp?: string };
|
||||||
|
cisco_umbrella?: { timestamp?: string };
|
||||||
|
};
|
||||||
|
vulnerabilities?: string[];
|
||||||
|
pem_encoded_certificiate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensysHostSearchResult {
|
||||||
|
hosts: CensysHost[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensysCertificateSearchResult {
|
||||||
|
certificates: CensysCertificate[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CensysExposure {
|
||||||
|
type: "exposed_service" | "outdated_tls" | "certificate_issue" | "open_database" | "admin_panel" | "default_credentials_risk";
|
||||||
|
severity: "info" | "warning" | "critical";
|
||||||
|
detail: string;
|
||||||
|
ip?: string;
|
||||||
|
port?: number;
|
||||||
|
service?: string;
|
||||||
|
vulnerabilityIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawHostSearchResponse {
|
||||||
|
result?: {
|
||||||
|
code?: number;
|
||||||
|
links?: { next?: string; prev?: string };
|
||||||
|
hosts?: CensysHost[];
|
||||||
|
};
|
||||||
|
meta?: {
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
pages?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawHostViewResponse {
|
||||||
|
result?: {
|
||||||
|
code?: number;
|
||||||
|
ip: string;
|
||||||
|
services: CensysService[];
|
||||||
|
locations?: CensysHost["locations"];
|
||||||
|
autonomous_system?: CensysHost["autonomous_system"];
|
||||||
|
dns?: CensysHost["dns"];
|
||||||
|
last_updated_at?: string;
|
||||||
|
metadata?: {
|
||||||
|
last_updated_at?: string;
|
||||||
|
Manny?: { first_observation?: string; last_observation?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCertSearchResponse {
|
||||||
|
result?: {
|
||||||
|
code?: number;
|
||||||
|
links?: { next?: string; prev?: string };
|
||||||
|
certificates?: CensysCertificate[];
|
||||||
|
};
|
||||||
|
meta?: {
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
pages?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Censys API Client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CACHE_PREFIX = "censys";
|
||||||
|
const HOST_CACHE_TTL = 604_800; // 7 days
|
||||||
|
const CERT_CACHE_TTL = 604_800; // 7 days
|
||||||
|
|
||||||
|
export class CensysClient {
|
||||||
|
private readonly apiId: string;
|
||||||
|
private readonly apiSecret: string;
|
||||||
|
private readonly hostsBaseUrl = "https://search.censys.io/api/v2/hosts";
|
||||||
|
private readonly certsBaseUrl = "https://search.censys.io/api/v2/certificates";
|
||||||
|
|
||||||
|
// Circuit breaker state
|
||||||
|
private circuitFailures = 0;
|
||||||
|
private circuitLastFailure = 0;
|
||||||
|
private circuitIsOpen = false;
|
||||||
|
private readonly circuitThreshold = 3;
|
||||||
|
private readonly circuitResetMs = 60_000;
|
||||||
|
|
||||||
|
// Rate limiting (200 req/min ≈ 300ms interval)
|
||||||
|
private lastRequestTime = 0;
|
||||||
|
private readonly minRequestIntervalMs: number;
|
||||||
|
|
||||||
|
constructor(apiId: string, apiSecret: string, requestsPerSecond = 3.33) {
|
||||||
|
this.apiId = apiId;
|
||||||
|
this.apiSecret = apiSecret;
|
||||||
|
this.minRequestIntervalMs = 1000 / Math.max(requestsPerSecond, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Circuit breaker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private isCircuitOpen(): boolean {
|
||||||
|
if (!this.circuitIsOpen) return false;
|
||||||
|
if (Date.now() - this.circuitLastFailure > this.circuitResetMs) {
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFailure(): void {
|
||||||
|
this.circuitFailures++;
|
||||||
|
this.circuitLastFailure = Date.now();
|
||||||
|
if (this.circuitFailures >= this.circuitThreshold) {
|
||||||
|
this.circuitIsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSuccess(): void {
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
this.circuitLastFailure = 0;
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rate limiter
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async waitForRateLimit(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastRequestTime;
|
||||||
|
if (elapsed < this.minRequestIntervalMs) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, this.minRequestIntervalMs - elapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.lastRequestTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Auth — Censys uses Basic auth with api_id:api_secret
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private getAuthHeader(): string {
|
||||||
|
const credentials = Buffer.from(`${this.apiId}:${this.apiSecret}`).toString("base64");
|
||||||
|
return `Basic ${credentials}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HTTP helper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async request<T>(url: string): Promise<T | null> {
|
||||||
|
if (this.isCircuitOpen()) {
|
||||||
|
throw new Error("Censys circuit breaker is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForRateLimit();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: this.getAuthHeader(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("Censys rate limit exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("Censys authentication failed — check API ID and SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(`Censys returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordSuccess();
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && (err.message.includes("circuit") || err.message.includes("rate limit") || err.message.includes("authentication"))) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.recordFailure();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// searchHosts — find exposed hosts by IP/domain/query
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async searchHosts(query: string, page = 1, perPage = 10): Promise<CensysHostSearchResult> {
|
||||||
|
const cacheKey = `host_search:${createHash("sha256").update(`${query}:${page}:${perPage}`).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<CensysHostSearchResult>(cacheKey, { prefix: CACHE_PREFIX, ttl: HOST_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${this.hostsBaseUrl}/search?q=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}`;
|
||||||
|
const data = await this.request<RawHostSearchResponse>(url);
|
||||||
|
if (!data || !data.result) return { hosts: [], total: 0, page, pages: 0 };
|
||||||
|
|
||||||
|
const result: CensysHostSearchResult = {
|
||||||
|
hosts: data.result.hosts ?? [],
|
||||||
|
total: data.meta?.total ?? 0,
|
||||||
|
page: data.meta?.page ?? page,
|
||||||
|
pages: data.meta?.pages ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(cacheKey, result, { prefix: CACHE_PREFIX, ttl: HOST_CACHE_TTL }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// viewHost — detailed host fingerprinting by IP
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async viewHost(ip: string): Promise<CensysHost | null> {
|
||||||
|
const cacheKey = `host:${createHash("sha256").update(ip.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<CensysHost>(cacheKey, { prefix: CACHE_PREFIX, ttl: HOST_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${this.hostsBaseUrl}/${encodeURIComponent(ip)}`;
|
||||||
|
try {
|
||||||
|
const data = await this.request<RawHostViewResponse>(url);
|
||||||
|
if (!data?.result) return null;
|
||||||
|
|
||||||
|
const host: CensysHost = {
|
||||||
|
ip: data.result.ip,
|
||||||
|
services: data.result.services ?? [],
|
||||||
|
locations: data.result.locations,
|
||||||
|
autonomous_system: data.result.autonomous_system,
|
||||||
|
dns: data.result.dns,
|
||||||
|
last_updated_at: data.result.last_updated_at ?? data.result.metadata?.last_updated_at,
|
||||||
|
timestamps: {
|
||||||
|
first_observation: data.result.metadata?.Manny?.first_observation,
|
||||||
|
last_observation: data.result.metadata?.Manny?.last_observation,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
set(cacheKey, host, { prefix: CACHE_PREFIX, ttl: HOST_CACHE_TTL }).catch(() => {});
|
||||||
|
return host;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getCertificates — certificate transparency logs for domain
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getCertificates(domain: string, page = 1, perPage = 10): Promise<CensysCertificateSearchResult> {
|
||||||
|
const cacheKey = `cert:${createHash("sha256").update(`${domain}:${page}`).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<CensysCertificateSearchResult>(cacheKey, { prefix: CACHE_PREFIX, ttl: CERT_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Search by certificate names field
|
||||||
|
const query = `certificate.names: ${domain}`;
|
||||||
|
const url = `${this.certsBaseUrl}/search?q=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}`;
|
||||||
|
const data = await this.request<RawCertSearchResponse>(url);
|
||||||
|
if (!data || !data.result) return { certificates: [], total: 0, page, pages: 0 };
|
||||||
|
|
||||||
|
const result: CensysCertificateSearchResult = {
|
||||||
|
certificates: data.result.certificates ?? [],
|
||||||
|
total: data.meta?.total ?? 0,
|
||||||
|
page: data.meta?.page ?? page,
|
||||||
|
pages: data.meta?.pages ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(cacheKey, result, { prefix: CACHE_PREFIX, ttl: CERT_CACHE_TTL }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeExposures — heuristic analysis of hosts/certs for security issues
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
analyzeHostExposures(host: CensysHost): CensysExposure[] {
|
||||||
|
const exposures: CensysExposure[] = [];
|
||||||
|
|
||||||
|
// Check for exposed services on sensitive ports
|
||||||
|
const sensitivePorts = new Map<number, { type: CensysExposure["type"]; severity: CensysExposure["severity"]; label: string }>([
|
||||||
|
[21, { type: "exposed_service", severity: "warning", label: "FTP" }],
|
||||||
|
[22, { type: "exposed_service", severity: "warning", label: "SSH" }],
|
||||||
|
[23, { type: "exposed_service", severity: "critical", label: "Telnet" }],
|
||||||
|
[25, { type: "exposed_service", severity: "warning", label: "SMTP" }],
|
||||||
|
[80, { type: "exposed_service", severity: "info", label: "HTTP" }],
|
||||||
|
[443, { type: "exposed_service", severity: "info", label: "HTTPS" }],
|
||||||
|
[3306, { type: "open_database", severity: "critical", label: "MySQL" }],
|
||||||
|
[5432, { type: "open_database", severity: "critical", label: "PostgreSQL" }],
|
||||||
|
[6379, { type: "open_database", severity: "critical", label: "Redis" }],
|
||||||
|
[9200, { type: "open_database", severity: "critical", label: "Elasticsearch" }],
|
||||||
|
[27017, { type: "open_database", severity: "critical", label: "MongoDB" }],
|
||||||
|
[8080, { type: "admin_panel", severity: "warning", label: "HTTP Alt" }],
|
||||||
|
[8443, { type: "admin_panel", severity: "warning", label: "HTTPS Alt" }],
|
||||||
|
[3389, { type: "exposed_service", severity: "critical", label: "RDP" }],
|
||||||
|
[5900, { type: "exposed_service", severity: "critical", label: "VNC" }],
|
||||||
|
[1433, { type: "open_database", severity: "critical", label: "MSSQL" }],
|
||||||
|
[1521, { type: "open_database", severity: "critical", label: "Oracle DB" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const service of host.services) {
|
||||||
|
const sensitive = sensitivePorts.get(service.port);
|
||||||
|
if (sensitive) {
|
||||||
|
exposures.push({
|
||||||
|
type: sensitive.type,
|
||||||
|
severity: sensitive.severity,
|
||||||
|
detail: `${sensitive.label} exposed on port ${service.port}${service.banner ? ` — ${service.banner.slice(0, 100)}` : ""}`,
|
||||||
|
ip: host.ip,
|
||||||
|
port: service.port,
|
||||||
|
service: service.service_name ?? sensitive.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exposures;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzeCertificateExposures(certificates: CensysCertificate[]): CensysExposure[] {
|
||||||
|
const exposures: CensysExposure[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const cert of certificates) {
|
||||||
|
// Expired certificate
|
||||||
|
if (cert.not_after) {
|
||||||
|
const expiry = new Date(cert.not_after);
|
||||||
|
if (expiry < now) {
|
||||||
|
exposures.push({
|
||||||
|
type: "certificate_issue",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Certificate expired: ${cert.not_after} (CN: ${cert.subject.common_name ?? cert.subject.cn ?? "unknown"})`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 30) {
|
||||||
|
exposures.push({
|
||||||
|
type: "certificate_issue",
|
||||||
|
severity: daysUntilExpiry < 7 ? "critical" : "warning",
|
||||||
|
detail: `Certificate expires in ${Math.ceil(daysUntilExpiry)} days (CN: ${cert.subject.common_name ?? cert.subject.cn ?? "unknown"})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-signed or untrusted issuer
|
||||||
|
const issuer = cert.issuer.cn ?? cert.issuer.common_name ?? "";
|
||||||
|
const issuerNormalized = issuer.toLowerCase().replace(/[\s'\'\'""\-]/g, "");
|
||||||
|
const trustedIssuers = ["letsencrypt", "digicert", "amazon", "google", "sslcom", "sectigo", "globalsign"];
|
||||||
|
const isTrusted = trustedIssuers.some((t) => issuerNormalized.includes(t));
|
||||||
|
if (!isTrusted && cert.issuer.organization !== cert.subject.organization) {
|
||||||
|
// Potential self-signed or untrusted CA
|
||||||
|
exposures.push({
|
||||||
|
type: "certificate_issue",
|
||||||
|
severity: "warning",
|
||||||
|
detail: `Untrusted certificate issuer: ${issuer} (CN: ${cert.subject.common_name ?? cert.subject.cn ?? "unknown"})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known vulnerabilities
|
||||||
|
if (cert.vulnerabilities?.length) {
|
||||||
|
exposures.push({
|
||||||
|
type: "certificate_issue",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Certificate has known vulnerabilities: ${cert.vulnerabilities.join(", ")}`,
|
||||||
|
vulnerabilityIds: cert.vulnerabilities,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exposures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cost tracking
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Censys Pro: $79/mo
|
||||||
|
static readonly ESTIMATED_COST_PER_REQUEST = 0.002; // ~$0.002 per request at Pro tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singleton accessor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let client: CensysClient | null = null;
|
||||||
|
|
||||||
|
export function getCensysClient(): CensysClient {
|
||||||
|
if (!client) {
|
||||||
|
const apiId = process.env.CENSYS_API_ID;
|
||||||
|
const apiSecret = process.env.CENSYS_API_SECRET;
|
||||||
|
if (!apiId || !apiSecret) {
|
||||||
|
throw new Error("CENSYS_API_ID and CENSYS_API_SECRET environment variables are required");
|
||||||
|
}
|
||||||
|
client = new CensysClient(apiId, apiSecret);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the singleton (useful for testing) */
|
||||||
|
export function resetCensysClient(): void {
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
44
web/src/server/services/darkwatch/digest.service.test.ts
Normal file
44
web/src/server/services/darkwatch/digest.service.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/server/services/notification.service", () => ({
|
||||||
|
sendEmail: vi.fn().mockResolvedValue({ id: "email-1" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { calculateNextDigestDate, DEFAULT_DIGEST_CONFIG } from "./digest.service";
|
||||||
|
|
||||||
|
describe("digest.service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateNextDigestDate", () => {
|
||||||
|
it("should return next daily digest date", () => {
|
||||||
|
const next = calculateNextDigestDate(DEFAULT_DIGEST_CONFIG);
|
||||||
|
expect(next).toBeInstanceOf(Date);
|
||||||
|
expect(next.getUTCHours()).toBe(9); // 9 AM UTC
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a future date for daily digest", () => {
|
||||||
|
const next = calculateNextDigestDate(DEFAULT_DIGEST_CONFIG);
|
||||||
|
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return weekly digest on correct day", () => {
|
||||||
|
const config = { ...DEFAULT_DIGEST_CONFIG, frequency: "weekly" as const, weeklyDay: 0 };
|
||||||
|
const next = calculateNextDigestDate(config);
|
||||||
|
expect(next.getUTCDay()).toBe(0); // Sunday
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DEFAULT_DIGEST_CONFIG", () => {
|
||||||
|
it("should batch info severity by default", () => {
|
||||||
|
expect(DEFAULT_DIGEST_CONFIG.batchedSeverities).toContain("info");
|
||||||
|
expect(DEFAULT_DIGEST_CONFIG.batchedSeverities).not.toContain("warning");
|
||||||
|
expect(DEFAULT_DIGEST_CONFIG.batchedSeverities).not.toContain("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use daily frequency by default", () => {
|
||||||
|
expect(DEFAULT_DIGEST_CONFIG.frequency).toBe("daily");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
333
web/src/server/services/darkwatch/digest.service.ts
Normal file
333
web/src/server/services/darkwatch/digest.service.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { eq, and, asc } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { digestAlerts, notificationPreferences } from "~/server/db/schema";
|
||||||
|
import { sendEmail } from "~/server/services/notification.service";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Digest configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DigestConfig {
|
||||||
|
/** Severity levels that get batched into digest (vs immediate) */
|
||||||
|
batchedSeverities: string[];
|
||||||
|
/** Digest frequency: "daily" or "weekly" */
|
||||||
|
frequency: "daily" | "weekly";
|
||||||
|
/** Time of day for daily digest (UTC hour) */
|
||||||
|
dailyHour: number;
|
||||||
|
/** Day of week for weekly digest (0=Sun) */
|
||||||
|
weeklyDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
|
||||||
|
batchedSeverities: ["info"],
|
||||||
|
frequency: "daily",
|
||||||
|
dailyHour: 9, // 9 AM UTC
|
||||||
|
weeklyDay: 0, // Sunday
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if an alert should be batched into a digest based on severity
|
||||||
|
* and user preferences.
|
||||||
|
*/
|
||||||
|
export async function shouldDigest(
|
||||||
|
userId: string,
|
||||||
|
severity: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [prefs] = await db
|
||||||
|
.select()
|
||||||
|
.from(notificationPreferences)
|
||||||
|
.where(eq(notificationPreferences.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// If user has no prefs, use defaults: info = digest, warning/critical = immediate
|
||||||
|
if (!prefs) {
|
||||||
|
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If email is disabled entirely, don't digest (alert won't be delivered)
|
||||||
|
if (!prefs.emailEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the next scheduled digest date based on config.
|
||||||
|
*/
|
||||||
|
export function calculateNextDigestDate(config: DigestConfig = DEFAULT_DIGEST_CONFIG): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
|
||||||
|
if (config.frequency === "daily") {
|
||||||
|
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||||
|
if (next.getTime() <= now.getTime()) {
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||||
|
const currentDay = next.getUTCDay();
|
||||||
|
const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7;
|
||||||
|
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
|
||||||
|
next.setUTCDate(next.getUTCDate() + 7);
|
||||||
|
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
|
||||||
|
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues an alert for the next digest email.
|
||||||
|
*/
|
||||||
|
export async function queueForDigest(
|
||||||
|
userId: string,
|
||||||
|
alertId: string,
|
||||||
|
title: string,
|
||||||
|
severity: string,
|
||||||
|
source: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const nextDigestDate = calculateNextDigestDate();
|
||||||
|
|
||||||
|
await db.insert(digestAlerts).values({
|
||||||
|
userId,
|
||||||
|
alertId,
|
||||||
|
title,
|
||||||
|
severity,
|
||||||
|
source,
|
||||||
|
scheduledDigestDate: nextDigestDate,
|
||||||
|
sent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the digest email for a user's pending alerts.
|
||||||
|
* Returns the number of alerts included in the digest.
|
||||||
|
*/
|
||||||
|
export async function sendDigestEmail(
|
||||||
|
userId: string,
|
||||||
|
scheduledDate: Date,
|
||||||
|
): Promise<number> {
|
||||||
|
const pendingAlerts = await db
|
||||||
|
.select()
|
||||||
|
.from(digestAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(digestAlerts.userId, userId),
|
||||||
|
eq(digestAlerts.sent, false),
|
||||||
|
eq(digestAlerts.scheduledDigestDate, scheduledDate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(digestAlerts.severity));
|
||||||
|
|
||||||
|
if (!pendingAlerts.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user email
|
||||||
|
const { users } = await import("~/server/db/schema/auth");
|
||||||
|
const [user] = await db
|
||||||
|
.select({ email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user?.email) {
|
||||||
|
console.warn(`[digest] No email found for user ${userId}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build digest email content
|
||||||
|
const alertsBySeverity = groupBySeverity(pendingAlerts);
|
||||||
|
const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail(
|
||||||
|
user.email,
|
||||||
|
`[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`,
|
||||||
|
html,
|
||||||
|
buildDigestPlainText(alertsBySeverity, pendingAlerts.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark alerts as sent
|
||||||
|
const alertIds = pendingAlerts.map((a) => a.id);
|
||||||
|
await db
|
||||||
|
.update(digestAlerts)
|
||||||
|
.set({ sent: true, sentAt: new Date() })
|
||||||
|
.where(and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])));
|
||||||
|
|
||||||
|
// Update all matching alerts
|
||||||
|
for (const alertId of alertIds) {
|
||||||
|
await db
|
||||||
|
.update(digestAlerts)
|
||||||
|
.set({ sent: true, sentAt: new Date() })
|
||||||
|
.where(eq(digestAlerts.id, alertId));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`);
|
||||||
|
return pendingAlerts.length;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[digest] Failed to send digest for user ${userId}:`, err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes all pending digests due for the current time.
|
||||||
|
* Called by the digest job scheduler.
|
||||||
|
*/
|
||||||
|
export async function processDueDigests(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.toISOString().split("T")[0]);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||||
|
|
||||||
|
// Find all users with pending digests due today
|
||||||
|
const { users } = await import("~/server/db/schema/auth");
|
||||||
|
|
||||||
|
// Get distinct userIds with pending digests
|
||||||
|
const pendingDigests = await db
|
||||||
|
.select({
|
||||||
|
userId: digestAlerts.userId,
|
||||||
|
scheduledDate: digestAlerts.scheduledDigestDate,
|
||||||
|
})
|
||||||
|
.from(digestAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(digestAlerts.sent, false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by user
|
||||||
|
const userMap = new Map<string, Date[]>();
|
||||||
|
for (const d of pendingDigests) {
|
||||||
|
const dates = userMap.get(d.userId) ?? [];
|
||||||
|
dates.push(d.scheduledDate);
|
||||||
|
userMap.set(d.userId, dates);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [userId, dates] of userMap) {
|
||||||
|
for (const date of [...new Set(dates)]) {
|
||||||
|
if (date.getTime() <= now.getTime()) {
|
||||||
|
await sendDigestEmail(userId, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Email template helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function groupBySeverity(
|
||||||
|
alerts: typeof digestAlerts.$InferInsert[],
|
||||||
|
): Record<string, typeof digestAlerts.$InferInsert[]> {
|
||||||
|
const groups: Record<string, typeof digestAlerts.$InferInsert[]> = {
|
||||||
|
critical: [],
|
||||||
|
warning: [],
|
||||||
|
info: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const key = alert.severity ?? "info";
|
||||||
|
if (groups[key]) {
|
||||||
|
groups[key].push(alert);
|
||||||
|
} else {
|
||||||
|
groups.info.push(alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDigestEmailHTML(
|
||||||
|
groups: Record<string, typeof digestAlerts.$InferInsert[]>,
|
||||||
|
total: number,
|
||||||
|
): string {
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
const severityConfig = [
|
||||||
|
{ key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" },
|
||||||
|
{ key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" },
|
||||||
|
{ key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { key, label, color, bg } of severityConfig) {
|
||||||
|
const alerts = groups[key];
|
||||||
|
if (!alerts.length) continue;
|
||||||
|
|
||||||
|
const rows = alerts
|
||||||
|
.map(
|
||||||
|
(a) =>
|
||||||
|
`<tr style="border-bottom:1px solid #eee">
|
||||||
|
<td style="padding:8px 12px"><span style="color:${color};font-weight:600;text-transform:uppercase;font-size:11px">${a.severity}</span></td>
|
||||||
|
<td style="padding:8px 12px">${escapeHtml(a.title)}</td>
|
||||||
|
<td style="padding:8px 12px;color:#666;font-size:12px">${escapeHtml(a.source)}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
sections.push(`
|
||||||
|
<div style="margin:16px 0;padding:12px;background:${bg};border-radius:8px;border-left:4px solid ${color}">
|
||||||
|
<h3 style="margin:0 0 8px 0;color:${color}">${label} (${alerts.length})</h3>
|
||||||
|
<table style="width:100%;border-collapse:collapse">${rows}</table>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
||||||
|
<h2 style="margin:0 0 4px 0">🛡️ Kordant Security Digest</h2>
|
||||||
|
<p style="color:#666;margin:0 0 24px 0">${total} alert${total > 1 ? "s" : ""} since your last digest</p>
|
||||||
|
${sections.join("")}
|
||||||
|
<p style="color:#999;font-size:12px;margin-top:24px">
|
||||||
|
This is an automated digest from Kordant. Critical and warning alerts are always sent immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDigestPlainText(
|
||||||
|
groups: Record<string, typeof digestAlerts.$InferInsert[]>,
|
||||||
|
total: number,
|
||||||
|
): string {
|
||||||
|
const lines = [`Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`, ""];
|
||||||
|
|
||||||
|
for (const [key, alerts] of Object.entries(groups)) {
|
||||||
|
if (!alerts.length) continue;
|
||||||
|
lines.push(`${key.toUpperCase()} (${alerts.length}):`);
|
||||||
|
for (const a of alerts) {
|
||||||
|
lines.push(` - ${a.title} [${a.source}]`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("This is an automated digest from Kordant.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up old digest records (older than 30 days).
|
||||||
|
*/
|
||||||
|
export async function cleanupOldDigests(): Promise<void> {
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(digestAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(digestAlerts.sent, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[digest] Cleaned up old digest records`);
|
||||||
|
}
|
||||||
379
web/src/server/services/darkwatch/hibp.client.test.ts
Normal file
379
web/src/server/services/darkwatch/hibp.client.test.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
HIBPClient,
|
||||||
|
calculateSeverityFromDataClasses,
|
||||||
|
resetHIBPClient,
|
||||||
|
} from "./hibp.client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// calculateSeverityFromDataClasses
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("calculateSeverityFromDataClasses", () => {
|
||||||
|
it("returns critical for SSN", () => {
|
||||||
|
expect(calculateSeverityFromDataClasses(["Social Security numbers"])).toBe(
|
||||||
|
"critical",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical for credit card numbers", () => {
|
||||||
|
expect(calculateSeverityFromDataClasses(["Credit card numbers"])).toBe(
|
||||||
|
"critical",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for email addresses", () => {
|
||||||
|
expect(calculateSeverityFromDataClasses(["Email addresses"])).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for phone numbers", () => {
|
||||||
|
expect(calculateSeverityFromDataClasses(["Phone numbers"])).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for passwords", () => {
|
||||||
|
expect(calculateSeverityFromDataClasses(["Passwords"])).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns info for usernames", () => {
|
||||||
|
expect(calculateSeverityFromDataClasses(["Usernames"])).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical when any data class is critical even if others are low", () => {
|
||||||
|
expect(
|
||||||
|
calculateSeverityFromDataClasses([
|
||||||
|
"Usernames",
|
||||||
|
"Email addresses",
|
||||||
|
"Social Security numbers",
|
||||||
|
]),
|
||||||
|
).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning when no critical but has warning data classes", () => {
|
||||||
|
expect(
|
||||||
|
calculateSeverityFromDataClasses(["Usernames", "Email addresses"]),
|
||||||
|
).toBe("warning");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HIBPClient – unit tests with mocked fetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("HIBPClient", () => {
|
||||||
|
const apiKey = "test-api-key";
|
||||||
|
let client: HIBPClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHIBPClient();
|
||||||
|
client = new HIBPClient(apiKey, 100); // high rate limit for tests
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// checkEmail
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("checkEmail", () => {
|
||||||
|
it("returns empty array on 404 (no breaches)", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 404 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.checkEmail("safe@example.com");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses breach results correctly", async () => {
|
||||||
|
const mockBreaches = [
|
||||||
|
{
|
||||||
|
Name: "TestBreach",
|
||||||
|
Title: "Test Breach",
|
||||||
|
Domain: "test.com",
|
||||||
|
BreachDate: "2023-01-15",
|
||||||
|
AddedDate: "2023-01-16T00:00:00Z",
|
||||||
|
ModifiedDate: "2023-01-16T00:00:00Z",
|
||||||
|
PwnCount: 1000,
|
||||||
|
Description: "A test breach",
|
||||||
|
LogoPath: "/logo.png",
|
||||||
|
DataClasses: ["Email addresses", "Passwords"],
|
||||||
|
IsVerified: true,
|
||||||
|
IsFabricated: false,
|
||||||
|
IsSensitive: false,
|
||||||
|
IsRetired: false,
|
||||||
|
IsSpamList: false,
|
||||||
|
IsMalware: false,
|
||||||
|
IsSubscriptionFree: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockBreaches), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.checkEmail("breached@test.com");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].breachName).toBe("TestBreach");
|
||||||
|
expect(result[0].breachDate).toBe("2023-01-15");
|
||||||
|
expect(result[0].dataClasses).toEqual(["Email addresses", "Passwords"]);
|
||||||
|
expect(result[0].domain).toBe("test.com");
|
||||||
|
expect(result[0].pwnCount).toBe(1000);
|
||||||
|
expect(result[0].isVerified).toBe(true);
|
||||||
|
expect(result[0].severity).toBe("warning"); // email + password -> warning
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical severity when breach contains SSN", async () => {
|
||||||
|
const mockBreaches = [
|
||||||
|
{
|
||||||
|
Name: "CriticalBreach",
|
||||||
|
Title: "Critical",
|
||||||
|
Domain: "bank.com",
|
||||||
|
BreachDate: "2024-01-01",
|
||||||
|
AddedDate: "2024-01-02T00:00:00Z",
|
||||||
|
ModifiedDate: "2024-01-02T00:00:00Z",
|
||||||
|
PwnCount: 500,
|
||||||
|
Description: "Critical data leak",
|
||||||
|
LogoPath: "/logo.png",
|
||||||
|
DataClasses: ["Email addresses", "Social Security numbers"],
|
||||||
|
IsVerified: true,
|
||||||
|
IsFabricated: false,
|
||||||
|
IsSensitive: true,
|
||||||
|
IsRetired: false,
|
||||||
|
IsSpamList: false,
|
||||||
|
IsMalware: false,
|
||||||
|
IsSubscriptionFree: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockBreaches), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.checkEmail("critical@test.com");
|
||||||
|
expect(result[0].severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 429 rate limit", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 429 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.checkEmail("test@test.com")).rejects.toThrow(
|
||||||
|
"HIBP rate limit exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 503 service unavailable", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 503 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.checkEmail("test@test.com")).rejects.toThrow(
|
||||||
|
"HIBP service unavailable",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens circuit breaker after 3 consecutive failures", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(null, { status: 503 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3 failures should open the circuit
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.checkEmail("test@test.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th call should be blocked by circuit breaker
|
||||||
|
await expect(client.checkEmail("test@test.com")).rejects.toThrow(
|
||||||
|
"HIBP circuit breaker is open",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets circuit breaker after a successful call", async () => {
|
||||||
|
vi.mocked(fetch).mockReset();
|
||||||
|
|
||||||
|
// Fail once
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 503 }),
|
||||||
|
);
|
||||||
|
await expect(client.checkEmail("t@t.com")).rejects.toThrow();
|
||||||
|
|
||||||
|
// Then succeed – counter resets
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await client.checkEmail("ok@test.com");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
// Counter should be reset; 3 more failures should open circuit
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }));
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.checkEmail("x@x.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
await expect(client.checkEmail("x@x.com")).rejects.toThrow(
|
||||||
|
"HIBP circuit breaker is open",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours rate limiting between requests", async () => {
|
||||||
|
// Each call gets a fresh Response so the body isn't consumed
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Override to create a new Response per call
|
||||||
|
vi.mocked(fetch).mockImplementation(
|
||||||
|
() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
) as Promise<Response>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await client.checkEmail("a@a.com");
|
||||||
|
await client.checkEmail("b@b.com");
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
expect(elapsed).toBeLessThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies slow rate limit with 1 req/sec", async () => {
|
||||||
|
const slowClient = new HIBPClient(apiKey, 1);
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockImplementation(
|
||||||
|
() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
) as Promise<Response>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await slowClient.checkEmail("a@a.com");
|
||||||
|
await slowClient.checkEmail("b@b.com");
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// checkPassword (k-anonymity)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("checkPassword", () => {
|
||||||
|
it("returns pwned when hash suffix is found", async () => {
|
||||||
|
// SHA-1 of "password" = 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
|
||||||
|
const suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8";
|
||||||
|
|
||||||
|
const mockResponse =
|
||||||
|
"1E4C9B93F3F0682250B6CF8331B7EE68FD8:8303945\n" +
|
||||||
|
"AABBCCDDEEFF00112233445566778899AABBCCDD:123\n";
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(mockResponse, { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.checkPassword(
|
||||||
|
"5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
|
||||||
|
);
|
||||||
|
expect(result.isPwned).toBe(true);
|
||||||
|
expect(result.count).toBe(8303945);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not pwned when hash suffix is absent", async () => {
|
||||||
|
const mockResponse = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:100\n";
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(mockResponse, { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.checkPassword(
|
||||||
|
"0000000000000000000000000000000000000000",
|
||||||
|
);
|
||||||
|
expect(result.isPwned).toBe(false);
|
||||||
|
expect(result.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.checkPassword("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||||
|
).rejects.toThrow("PwnedPasswords returned HTTP 500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getBreaches
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getBreaches", () => {
|
||||||
|
it("returns full breach list", async () => {
|
||||||
|
const mockBreaches = [
|
||||||
|
{
|
||||||
|
Name: "Adobe",
|
||||||
|
Title: "Adobe",
|
||||||
|
Domain: "adobe.com",
|
||||||
|
BreachDate: "2013-10-04",
|
||||||
|
AddedDate: "2013-12-04T00:00:00Z",
|
||||||
|
ModifiedDate: "2013-12-04T00:00:00Z",
|
||||||
|
PwnCount: 152445165,
|
||||||
|
Description: "In October 2013...",
|
||||||
|
LogoPath: "/logo.png",
|
||||||
|
DataClasses: ["Email addresses", "Password hints", "Passwords"],
|
||||||
|
IsVerified: true,
|
||||||
|
IsFabricated: false,
|
||||||
|
IsSensitive: false,
|
||||||
|
IsRetired: false,
|
||||||
|
IsSpamList: false,
|
||||||
|
IsMalware: false,
|
||||||
|
IsSubscriptionFree: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockBreaches), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getBreaches();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].Name).toBe("Adobe");
|
||||||
|
expect(result[0].PwnCount).toBe(152445165);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on error", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.getBreaches()).rejects.toThrow(
|
||||||
|
"HIBP breaches endpoint returned HTTP 500",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
389
web/src/server/services/darkwatch/hibp.client.ts
Normal file
389
web/src/server/services/darkwatch/hibp.client.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { get, set } from "~/server/lib/cache";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Breach {
|
||||||
|
Name: string;
|
||||||
|
Title: string;
|
||||||
|
Domain: string;
|
||||||
|
BreachDate: string;
|
||||||
|
AddedDate: string;
|
||||||
|
ModifiedDate: string;
|
||||||
|
PwnCount: number;
|
||||||
|
Description: string;
|
||||||
|
LogoPath: string;
|
||||||
|
DataClasses: string[];
|
||||||
|
IsVerified: boolean;
|
||||||
|
IsFabricated: boolean;
|
||||||
|
IsSensitive: boolean;
|
||||||
|
IsRetired: boolean;
|
||||||
|
IsSpamList: boolean;
|
||||||
|
IsMalware: boolean;
|
||||||
|
IsSubscriptionFree: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreachResult {
|
||||||
|
breachName: string;
|
||||||
|
breachDate: string;
|
||||||
|
dataClasses: string[];
|
||||||
|
description: string;
|
||||||
|
domain: string;
|
||||||
|
pwnCount: number;
|
||||||
|
isVerified: boolean;
|
||||||
|
isSensitive: boolean;
|
||||||
|
isSpamList: boolean;
|
||||||
|
isMalware: boolean;
|
||||||
|
severity: "info" | "warning" | "critical";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PwnedPasswordResult {
|
||||||
|
isPwned: boolean;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Severity calculation from HIBP DataClasses
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CRITICAL_DATA_CLASSES = new Set([
|
||||||
|
"Social Security numbers",
|
||||||
|
"Credit card numbers",
|
||||||
|
"Credit card CVV",
|
||||||
|
"Bank account numbers",
|
||||||
|
"Financial data",
|
||||||
|
"Financial information",
|
||||||
|
"National ID",
|
||||||
|
"Passport number",
|
||||||
|
"Driver's licenses",
|
||||||
|
"Tax IDs",
|
||||||
|
"Medical records",
|
||||||
|
"Health insurance information",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const WARNING_DATA_CLASSES = new Set([
|
||||||
|
"Email addresses",
|
||||||
|
"Phone numbers",
|
||||||
|
"Physical addresses",
|
||||||
|
"Passwords",
|
||||||
|
"Password hints",
|
||||||
|
"IP addresses",
|
||||||
|
"Account balances",
|
||||||
|
"Security questions and answers",
|
||||||
|
"Personal descriptions",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function calculateSeverityFromDataClasses(
|
||||||
|
dataClasses: string[],
|
||||||
|
): "info" | "warning" | "critical" {
|
||||||
|
for (const dc of dataClasses) {
|
||||||
|
if (CRITICAL_DATA_CLASSES.has(dc)) return "critical";
|
||||||
|
}
|
||||||
|
for (const dc of dataClasses) {
|
||||||
|
if (WARNING_DATA_CLASSES.has(dc)) return "warning";
|
||||||
|
}
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HIBP API Client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HIBP_BREACHES_CACHE_KEY = "breaches";
|
||||||
|
const HIBP_BREACHES_CACHE_TTL = 86_400; // 24 hours
|
||||||
|
|
||||||
|
export class HIBPClient {
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly baseUrl = "https://haveibeenpwned.com/api/v3";
|
||||||
|
private readonly pwnedPasswordsUrl = "https://api.pwnedpasswords.com";
|
||||||
|
private readonly userAgent = "Kordant-DarkWatch";
|
||||||
|
private readonly minRequestIntervalMs: number;
|
||||||
|
|
||||||
|
// Circuit breaker state
|
||||||
|
private circuitFailures = 0;
|
||||||
|
private circuitLastFailure = 0;
|
||||||
|
private circuitIsOpen = false;
|
||||||
|
private readonly circuitThreshold = 3;
|
||||||
|
private readonly circuitResetMs = 60_000;
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
private lastRequestTime = 0;
|
||||||
|
|
||||||
|
constructor(apiKey: string, requestsPerSecond = 1) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.minRequestIntervalMs = 1000 / Math.max(requestsPerSecond, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Circuit breaker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private isCircuitOpen(): boolean {
|
||||||
|
if (!this.circuitIsOpen) return false;
|
||||||
|
if (Date.now() - this.circuitLastFailure > this.circuitResetMs) {
|
||||||
|
// Half-open: allow a probe request
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFailure(): void {
|
||||||
|
this.circuitFailures++;
|
||||||
|
this.circuitLastFailure = Date.now();
|
||||||
|
if (this.circuitFailures >= this.circuitThreshold) {
|
||||||
|
this.circuitIsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSuccess(): void {
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
this.circuitLastFailure = 0;
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rate limiter – enforces minimum interval between requests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async waitForRateLimit(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastRequestTime;
|
||||||
|
if (elapsed < this.minRequestIntervalMs) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, this.minRequestIntervalMs - elapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.lastRequestTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// checkEmail – query breachedaccount endpoint
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async checkEmail(email: string): Promise<BreachResult[]> {
|
||||||
|
if (this.isCircuitOpen()) {
|
||||||
|
throw new Error("HIBP circuit breaker is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForRateLimit();
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/breachedaccount/${encodeURIComponent(email)}?truncateResponse=false`;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"hibp-api-key": this.apiKey,
|
||||||
|
"user-agent": this.userAgent,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(
|
||||||
|
`HIBP request failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 = no breaches found (not an error)
|
||||||
|
if (res.status === 404) {
|
||||||
|
this.recordSuccess();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limited
|
||||||
|
if (res.status === 429) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("HIBP rate limit exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service unavailable
|
||||||
|
if (res.status === 503) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("HIBP service unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(`HIBP returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordSuccess();
|
||||||
|
|
||||||
|
let breaches: Breach[];
|
||||||
|
try {
|
||||||
|
breaches = (await res.json()) as Breach[];
|
||||||
|
} catch {
|
||||||
|
throw new Error("HIBP returned invalid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
return breaches.map((b) => ({
|
||||||
|
breachName: b.Name,
|
||||||
|
breachDate: b.BreachDate,
|
||||||
|
dataClasses: b.DataClasses,
|
||||||
|
description: b.Description,
|
||||||
|
domain: b.Domain,
|
||||||
|
pwnCount: b.PwnCount,
|
||||||
|
isVerified: b.IsVerified,
|
||||||
|
isSensitive: b.IsSensitive,
|
||||||
|
isSpamList: b.IsSpamList,
|
||||||
|
isMalware: b.IsMalware,
|
||||||
|
severity: calculateSeverityFromDataClasses(b.DataClasses),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// checkPassword – k-anonymity lookup via pwnedpasswords API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async checkPassword(passwordHash: string): Promise<PwnedPasswordResult> {
|
||||||
|
const prefix = passwordHash.substring(0, 5).toUpperCase();
|
||||||
|
const suffix = passwordHash.substring(5).toUpperCase();
|
||||||
|
|
||||||
|
if (this.isCircuitOpen()) {
|
||||||
|
throw new Error("HIBP circuit breaker is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForRateLimit();
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${this.pwnedPasswordsUrl}/range/${prefix}`,
|
||||||
|
{
|
||||||
|
headers: { "user-agent": this.userAgent },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(
|
||||||
|
`PwnedPasswords request failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(`PwnedPasswords returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordSuccess();
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await res.text();
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to read PwnedPasswords response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const [hashSuffix, countStr] = trimmed.split(":");
|
||||||
|
if (hashSuffix?.toUpperCase() === suffix) {
|
||||||
|
return { isPwned: true, count: parseInt(countStr ?? "0", 10) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isPwned: false, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getBreaches – fetch full breach metadata list (for caching)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getBreaches(): Promise<Breach[]> {
|
||||||
|
if (this.isCircuitOpen()) {
|
||||||
|
throw new Error("HIBP circuit breaker is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForRateLimit();
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${this.baseUrl}/breaches`, {
|
||||||
|
headers: {
|
||||||
|
"hibp-api-key": this.apiKey,
|
||||||
|
"user-agent": this.userAgent,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(
|
||||||
|
`HIBP breaches request failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(`HIBP breaches endpoint returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordSuccess();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await res.json()) as Breach[];
|
||||||
|
} catch {
|
||||||
|
throw new Error("HIBP returned invalid JSON for breaches");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singleton accessor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let client: HIBPClient | null = null;
|
||||||
|
|
||||||
|
export function getHIBPClient(): HIBPClient {
|
||||||
|
if (!client) {
|
||||||
|
const apiKey = process.env.HIBP_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("HIBP_API_KEY environment variable is required for HIBP client");
|
||||||
|
}
|
||||||
|
const ratePerSecond = parseInt(
|
||||||
|
process.env.HIBP_RATE_PER_SECOND ?? "1",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
client = new HIBPClient(apiKey, ratePerSecond);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the singleton (useful for testing) */
|
||||||
|
export function resetHIBPClient(): void {
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cached breach metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full breach metadata list, using a 24-hour Redis cache when
|
||||||
|
* available. Falls back to a direct API call on cache miss.
|
||||||
|
*/
|
||||||
|
export async function getCachedBreaches(): Promise<Breach[]> {
|
||||||
|
const cached = await get<Breach[]>(HIBP_BREACHES_CACHE_KEY, {
|
||||||
|
prefix: "hibp",
|
||||||
|
});
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const hibp = getHIBPClient();
|
||||||
|
const breaches = await hibp.getBreaches();
|
||||||
|
|
||||||
|
// Fire-and-forget cache write – never block on cache
|
||||||
|
set(HIBP_BREACHES_CACHE_KEY, breaches, {
|
||||||
|
prefix: "hibp",
|
||||||
|
ttl: HIBP_BREACHES_CACHE_TTL,
|
||||||
|
}).catch(() => {
|
||||||
|
/* cache is optional */
|
||||||
|
});
|
||||||
|
|
||||||
|
return breaches;
|
||||||
|
}
|
||||||
205
web/src/server/services/darkwatch/scan-events.test.ts
Normal file
205
web/src/server/services/darkwatch/scan-events.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/server/websocket", () => ({
|
||||||
|
broadcastScanEvent: vi.fn().mockReturnValue(true),
|
||||||
|
broadcastToUser: vi.fn().mockReturnValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
eq: vi.fn(),
|
||||||
|
and: vi.fn(),
|
||||||
|
desc: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { broadcastScanEvent, type ScanStartedEvent, type ScanProgressEvent, type ScanCompletedEvent, type ScanFailedEvent, type ScanQueueEvent } from "~/server/websocket";
|
||||||
|
|
||||||
|
describe("WebSocket scan events", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ScanStartedEvent", () => {
|
||||||
|
it("should have correct structure", () => {
|
||||||
|
const event: ScanStartedEvent = {
|
||||||
|
type: "scan:started",
|
||||||
|
scanId: "scan-123",
|
||||||
|
totalSources: 4,
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.type).toBe("scan:started");
|
||||||
|
expect(event.scanId).toBe("scan-123");
|
||||||
|
expect(event.totalSources).toBe(4);
|
||||||
|
expect(event.userId).toBe("user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should broadcast scan started event", () => {
|
||||||
|
const event: ScanStartedEvent = {
|
||||||
|
type: "scan:started",
|
||||||
|
scanId: "scan-123",
|
||||||
|
totalSources: 4,
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastScanEvent("user-1", event);
|
||||||
|
|
||||||
|
expect(broadcastScanEvent).toHaveBeenCalledWith("user-1", event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ScanProgressEvent", () => {
|
||||||
|
it("should have correct structure with percentage", () => {
|
||||||
|
const event: ScanProgressEvent = {
|
||||||
|
type: "scan:progress",
|
||||||
|
scanId: "scan-123",
|
||||||
|
completedSources: 2,
|
||||||
|
totalSources: 4,
|
||||||
|
percentage: 50,
|
||||||
|
currentSource: "hibp",
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.type).toBe("scan:progress");
|
||||||
|
expect(event.percentage).toBe(50);
|
||||||
|
expect(event.completedSources).toBe(2);
|
||||||
|
expect(event.totalSources).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate correct percentage", () => {
|
||||||
|
const completed = 1;
|
||||||
|
const total = 4;
|
||||||
|
const percentage = Math.round((completed / total) * 100);
|
||||||
|
expect(percentage).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 100% completion", () => {
|
||||||
|
const completed = 4;
|
||||||
|
const total = 4;
|
||||||
|
const percentage = Math.round((completed / total) * 100);
|
||||||
|
expect(percentage).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ScanCompletedEvent", () => {
|
||||||
|
it("should have correct structure", () => {
|
||||||
|
const event: ScanCompletedEvent = {
|
||||||
|
type: "scan:completed",
|
||||||
|
scanId: "scan-123",
|
||||||
|
exposuresFound: 10,
|
||||||
|
newExposures: 3,
|
||||||
|
alertsGenerated: 2,
|
||||||
|
alertsSuppressed: 1,
|
||||||
|
durationMs: 5000,
|
||||||
|
threatScore: 42,
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.type).toBe("scan:completed");
|
||||||
|
expect(event.exposuresFound).toBe(10);
|
||||||
|
expect(event.newExposures).toBe(3);
|
||||||
|
expect(event.alertsGenerated).toBe(2);
|
||||||
|
expect(event.alertsSuppressed).toBe(1);
|
||||||
|
expect(event.durationMs).toBe(5000);
|
||||||
|
expect(event.threatScore).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle no failed sources", () => {
|
||||||
|
const event: ScanCompletedEvent = {
|
||||||
|
type: "scan:completed",
|
||||||
|
scanId: "scan-123",
|
||||||
|
exposuresFound: 0,
|
||||||
|
newExposures: 0,
|
||||||
|
alertsGenerated: 0,
|
||||||
|
alertsSuppressed: 0,
|
||||||
|
durationMs: 2000,
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.failedSources).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include failed sources when present", () => {
|
||||||
|
const event: ScanCompletedEvent = {
|
||||||
|
type: "scan:completed",
|
||||||
|
scanId: "scan-123",
|
||||||
|
exposuresFound: 5,
|
||||||
|
newExposures: 1,
|
||||||
|
alertsGenerated: 1,
|
||||||
|
alertsSuppressed: 0,
|
||||||
|
durationMs: 8000,
|
||||||
|
failedSources: ["shodan", "censys"],
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.failedSources).toEqual(["shodan", "censys"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ScanFailedEvent", () => {
|
||||||
|
it("should have correct structure", () => {
|
||||||
|
const event: ScanFailedEvent = {
|
||||||
|
type: "scan:failed",
|
||||||
|
scanId: "scan-123",
|
||||||
|
error: "API rate limit exceeded",
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.type).toBe("scan:failed");
|
||||||
|
expect(event.error).toBe("API rate limit exceeded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ScanQueueEvent", () => {
|
||||||
|
it("should have correct structure", () => {
|
||||||
|
const event: ScanQueueEvent = {
|
||||||
|
type: "scan:queued",
|
||||||
|
scanId: "scan-456",
|
||||||
|
position: 2,
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(event.type).toBe("scan:queued");
|
||||||
|
expect(event.position).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("broadcastScanEvent", () => {
|
||||||
|
it("should broadcast to correct user", () => {
|
||||||
|
const event: ScanStartedEvent = {
|
||||||
|
type: "scan:started",
|
||||||
|
scanId: "scan-123",
|
||||||
|
totalSources: 4,
|
||||||
|
userId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastScanEvent("user-1", event);
|
||||||
|
|
||||||
|
expect(broadcastScanEvent).toHaveBeenCalledWith("user-1", event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different event types", () => {
|
||||||
|
const events = [
|
||||||
|
{ type: "scan:started", scanId: "s1", totalSources: 1, userId: "u1" },
|
||||||
|
{ type: "scan:progress", scanId: "s1", completedSources: 1, totalSources: 1, percentage: 100, userId: "u1" },
|
||||||
|
{ type: "scan:completed", scanId: "s1", exposuresFound: 0, newExposures: 0, alertsGenerated: 0, alertsSuppressed: 0, durationMs: 100, userId: "u1" },
|
||||||
|
{ type: "scan:failed", scanId: "s1", error: "timeout", userId: "u1" },
|
||||||
|
{ type: "scan:queued", scanId: "s2", position: 1, userId: "u1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
broadcastScanEvent("u1", event);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(broadcastScanEvent).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,28 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { severityScore } from "./alert.pipeline";
|
import { severityScore } from "./alert.pipeline";
|
||||||
|
import {
|
||||||
|
processScanResult,
|
||||||
|
scanHIBP,
|
||||||
|
scanSecurityTrails,
|
||||||
|
scanCensys,
|
||||||
|
scanShodan,
|
||||||
|
} from "./scan.engine";
|
||||||
|
import { resetHIBPClient } from "./hibp.client";
|
||||||
|
import { resetSecurityTrailsClient } from "./securitytrails.client";
|
||||||
|
import { resetCensysClient } from "./censys.client";
|
||||||
|
import { resetShodanClient } from "./shodan.client";
|
||||||
|
|
||||||
|
const mockOptions = {
|
||||||
|
subscriptionId: "test-sub",
|
||||||
|
tier: "premium" as const,
|
||||||
|
watchlistItemId: "test-watchlist",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// severityScore — updated with exposure type awareness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("severityScore", () => {
|
describe("severityScore", () => {
|
||||||
it("returns critical for HIBP source", () => {
|
|
||||||
expect(severityScore({ source: "hibp", dataType: "email" })).toBe("critical");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns critical for ssn data type", () => {
|
it("returns critical for ssn data type", () => {
|
||||||
expect(severityScore({ source: "darkWebForum", dataType: "ssn" })).toBe("critical");
|
expect(severityScore({ source: "darkWebForum", dataType: "ssn" })).toBe("critical");
|
||||||
});
|
});
|
||||||
@@ -21,4 +38,456 @@ describe("severityScore", () => {
|
|||||||
it("returns info for low-risk combinations", () => {
|
it("returns info for low-risk combinations", () => {
|
||||||
expect(severityScore({ source: "securityTrails", dataType: "domain" })).toBe("info");
|
expect(severityScore({ source: "securityTrails", dataType: "domain" })).toBe("info");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns critical when metadata has SSN data class", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "hibp",
|
||||||
|
dataType: "email",
|
||||||
|
metadata: { dataClasses: ["Email addresses", "Social Security numbers"] },
|
||||||
|
}),
|
||||||
|
).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning when metadata has email/phone only", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "hibp",
|
||||||
|
dataType: "email",
|
||||||
|
metadata: { dataClasses: ["Email addresses"] },
|
||||||
|
}),
|
||||||
|
).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns info when metadata has only low-risk data classes", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "hibp",
|
||||||
|
dataType: "email",
|
||||||
|
metadata: { dataClasses: ["Usernames", "Dates of birth"] },
|
||||||
|
}),
|
||||||
|
).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for HIBP without metadata (dataType fallback)", () => {
|
||||||
|
expect(severityScore({ source: "hibp", dataType: "email" })).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
// New: exposure type-based scoring
|
||||||
|
it("returns critical for open_database exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "censys",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "open_database" },
|
||||||
|
}),
|
||||||
|
).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical for admin_panel exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "shodan",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "admin_panel" },
|
||||||
|
}),
|
||||||
|
).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical for default_credentials exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "shodan",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "default_credentials" },
|
||||||
|
}),
|
||||||
|
).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical for vulnerable_service exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "shodan",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "vulnerable_service" },
|
||||||
|
}),
|
||||||
|
).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for dns_misconfiguration exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "securityTrails",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "dns_misconfiguration" },
|
||||||
|
}),
|
||||||
|
).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for certificate_issue exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "censys",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "certificate_issue" },
|
||||||
|
}),
|
||||||
|
).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning for exposed_service exposure type", () => {
|
||||||
|
expect(
|
||||||
|
severityScore({
|
||||||
|
source: "shodan",
|
||||||
|
dataType: "domain",
|
||||||
|
metadata: { exposureType: "exposed_service" },
|
||||||
|
}),
|
||||||
|
).toBe("warning");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// processScanResult — unified normalization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("processScanResult", () => {
|
||||||
|
it("normalizes SecurityTrails exposure", () => {
|
||||||
|
const result = processScanResult(
|
||||||
|
"securityTrails",
|
||||||
|
{
|
||||||
|
type: "dns_misconfiguration",
|
||||||
|
severity: "warning",
|
||||||
|
detail: "SPF without DMARC",
|
||||||
|
recordType: "TXT",
|
||||||
|
},
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.source).toBe("securityTrails");
|
||||||
|
expect(result.dataType).toBe("domain");
|
||||||
|
expect(result.severity).toBe("warning");
|
||||||
|
expect(result.metadata.exposureType).toBe("dns_misconfiguration");
|
||||||
|
expect(result.metadata.detail).toBe("SPF without DMARC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes Censys exposure with IP", () => {
|
||||||
|
const result = processScanResult(
|
||||||
|
"censys",
|
||||||
|
{
|
||||||
|
type: "open_database",
|
||||||
|
severity: "critical",
|
||||||
|
detail: "MySQL exposed",
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
port: 3306,
|
||||||
|
service: "MySQL",
|
||||||
|
},
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.source).toBe("censys");
|
||||||
|
expect(result.identifier).toBe("1.2.3.4");
|
||||||
|
expect(result.severity).toBe("critical");
|
||||||
|
expect(result.metadata.ip).toBe("1.2.3.4");
|
||||||
|
expect(result.metadata.port).toBe(3306);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes Shodan exposure with vulns", () => {
|
||||||
|
const result = processScanResult(
|
||||||
|
"shodan",
|
||||||
|
{
|
||||||
|
type: "vulnerable_service",
|
||||||
|
severity: "critical",
|
||||||
|
detail: "CVE found",
|
||||||
|
ip: "5.6.7.8",
|
||||||
|
port: 443,
|
||||||
|
vulns: ["CVE-2021-44228"],
|
||||||
|
},
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.source).toBe("shodan");
|
||||||
|
expect(result.identifier).toBe("5.6.7.8");
|
||||||
|
expect(result.metadata.vulns).toContain("CVE-2021-44228");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates consistent identifierHash", () => {
|
||||||
|
const result1 = processScanResult(
|
||||||
|
"shodan",
|
||||||
|
{ type: "open_port", severity: "info", detail: "test", ip: "1.2.3.4", port: 80 },
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
const result2 = processScanResult(
|
||||||
|
"shodan",
|
||||||
|
{ type: "open_port", severity: "info", detail: "test", ip: "1.2.3.4", port: 80 },
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result1.identifierHash).toBe(result2.identifierHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// scanHIBP — tier-aware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("scanHIBP", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHIBPClient();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs HIBP scan for premium tier", async () => {
|
||||||
|
process.env.HIBP_API_KEY = "test-key";
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scanHIBP("test@example.com", mockOptions);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(fetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs HIBP scan for basic tier", async () => {
|
||||||
|
process.env.HIBP_API_KEY = "test-key";
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scanHIBP("test@example.com", {
|
||||||
|
...mockOptions,
|
||||||
|
tier: "basic",
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips HIBP scan when API key missing", async () => {
|
||||||
|
delete process.env.HIBP_API_KEY;
|
||||||
|
|
||||||
|
const result = await scanHIBP("test@example.com", mockOptions);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// scanSecurityTrails — tier-aware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("scanSecurityTrails", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSecurityTrailsClient();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs SecurityTrails scan for premium tier", async () => {
|
||||||
|
process.env.SECURITYTRAILS_API_KEY = "test-key";
|
||||||
|
|
||||||
|
// Mock the 3 parallel requests from getDomainInfo
|
||||||
|
vi.mocked(fetch)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ records: { A: ["1.2.3.4"] } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ registrar: "Test", expiration_date: "2026-01-01" }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ subdomains: ["www", "mail"] }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scanSecurityTrails("example.com", mockOptions);
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips SecurityTrails for basic tier", async () => {
|
||||||
|
process.env.SECURITYTRAILS_API_KEY = "test-key";
|
||||||
|
|
||||||
|
const result = await scanSecurityTrails("example.com", {
|
||||||
|
...mockOptions,
|
||||||
|
tier: "basic",
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when API key missing", async () => {
|
||||||
|
delete process.env.SECURITYTRAILS_API_KEY;
|
||||||
|
|
||||||
|
const result = await scanSecurityTrails("example.com", mockOptions);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// scanCensys — tier-aware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("scanCensys", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCensysClient();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs Censys scan for premium tier", async () => {
|
||||||
|
process.env.CENSYS_API_ID = "test-id";
|
||||||
|
process.env.CENSYS_API_SECRET = "test-secret";
|
||||||
|
|
||||||
|
// Mock host search
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
result: { hosts: [{ ip: "1.2.3.4", services: [{ port: 80 }] }] },
|
||||||
|
meta: { total: 1 },
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock cert search
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
result: { certificates: [] },
|
||||||
|
meta: { total: 0 },
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scanCensys("example.com", mockOptions);
|
||||||
|
expect(fetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips Censys for basic tier", async () => {
|
||||||
|
process.env.CENSYS_API_ID = "test-id";
|
||||||
|
process.env.CENSYS_API_SECRET = "test-secret";
|
||||||
|
|
||||||
|
const result = await scanCensys("example.com", {
|
||||||
|
...mockOptions,
|
||||||
|
tier: "basic",
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when API ID/SECRET missing", async () => {
|
||||||
|
delete process.env.CENSYS_API_ID;
|
||||||
|
delete process.env.CENSYS_API_SECRET;
|
||||||
|
|
||||||
|
const result = await scanCensys("example.com", mockOptions);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// scanShodan — tier-aware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("scanShodan", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetShodanClient();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs Shodan scan for premium tier (domain)", async () => {
|
||||||
|
process.env.SHODAN_API_KEY = "test-key";
|
||||||
|
|
||||||
|
// Mock count
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ total: 1 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock search
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [80],
|
||||||
|
data: [{ port: 80, product: "nginx" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scanShodan("example.com", mockOptions);
|
||||||
|
expect(fetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs Shodan scan for IP directly", async () => {
|
||||||
|
process.env.SHODAN_API_KEY = "test-key";
|
||||||
|
|
||||||
|
// Mock host lookup
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [80],
|
||||||
|
data: [{ port: 80, product: "nginx" }],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await scanShodan("1.2.3.4", mockOptions);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips Shodan for basic tier", async () => {
|
||||||
|
process.env.SHODAN_API_KEY = "test-key";
|
||||||
|
|
||||||
|
const result = await scanShodan("example.com", {
|
||||||
|
...mockOptions,
|
||||||
|
tier: "basic",
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when API key missing", async () => {
|
||||||
|
delete process.env.SHODAN_API_KEY;
|
||||||
|
|
||||||
|
const result = await scanShodan("example.com", mockOptions);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { exposures, scanCosts } from "~/server/db/schema";
|
||||||
|
import { getHIBPClient } from "./hibp.client";
|
||||||
|
import { getSecurityTrailsClient } from "./securitytrails.client";
|
||||||
|
import { getCensysClient } from "./censys.client";
|
||||||
|
import { getShodanClient } from "./shodan.client";
|
||||||
|
import type { SecurityTrailsDomainInfo, SecurityTrailsExposure } from "./securitytrails.client";
|
||||||
|
import type { CensysHost, CensysCertificate, CensysExposure } from "./censys.client";
|
||||||
|
import type { ShodanHost, ShodanExposure } from "./shodan.client";
|
||||||
|
|
||||||
interface ScanResult {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
source: "hibp" | "securityTrails" | "censys" | "shodan" | "darkWebForum";
|
source: "hibp" | "securityTrails" | "censys" | "shodan" | "darkWebForum";
|
||||||
dataType: "email" | "phoneNumber" | "ssn" | "address" | "domain";
|
dataType: "email" | "phoneNumber" | "ssn" | "address" | "domain";
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -10,171 +24,470 @@ interface ScanResult {
|
|||||||
severity: "info" | "warning" | "critical";
|
severity: "info" | "warning" | "critical";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CircuitState {
|
export interface ScanCostRecord {
|
||||||
failures: number;
|
subscriptionId: string;
|
||||||
lastFailure: number;
|
source: string;
|
||||||
isOpen: boolean;
|
identifier: string;
|
||||||
|
apiCalls: number;
|
||||||
|
estimatedCost: number;
|
||||||
|
cacheHits: number;
|
||||||
|
scanDurationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const circuits = new Map<string, CircuitState>();
|
export interface ScanOptions {
|
||||||
const THRESHOLD = 5;
|
subscriptionId: string;
|
||||||
const RESET_MS = 60_000;
|
tier: "basic" | "plus" | "premium";
|
||||||
|
watchlistItemId?: string;
|
||||||
function isCircuitOpen(name: string): boolean {
|
|
||||||
const state = circuits.get(name);
|
|
||||||
if (!state) return false;
|
|
||||||
if (!state.isOpen) return false;
|
|
||||||
if (Date.now() - state.lastFailure > RESET_MS) {
|
|
||||||
circuits.delete(name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordFailure(name: string): void {
|
// Cost per request estimates (from client classes)
|
||||||
const state = circuits.get(name) ?? { failures: 0, lastFailure: 0, isOpen: false };
|
const COST_PER_REQUEST = {
|
||||||
state.failures++;
|
hibp: 0.0005,
|
||||||
state.lastFailure = Date.now();
|
securityTrails: 0.001,
|
||||||
if (state.failures >= THRESHOLD) {
|
censys: 0.002,
|
||||||
state.isOpen = true;
|
shodan: 0.005,
|
||||||
}
|
darkWebForum: 0,
|
||||||
circuits.set(name, state);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function recordSuccess(name: string): void {
|
// Sources available per tier
|
||||||
circuits.delete(name);
|
const TIER_SOURCES: Record<string, Set<string>> = {
|
||||||
}
|
basic: new Set(["hibp"]),
|
||||||
|
plus: new Set(["hibp", "securityTrails", "censys", "shodan"]),
|
||||||
|
premium: new Set(["hibp", "securityTrails", "censys", "shodan"]),
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchWithCircuit(name: string, url: string, headers: Record<string, string>): Promise<Response | null> {
|
// ---------------------------------------------------------------------------
|
||||||
if (isCircuitOpen(name)) {
|
// Scan cost tracking
|
||||||
console.warn(`[darkwatch] Circuit open for ${name}, skipping`);
|
// ---------------------------------------------------------------------------
|
||||||
return null;
|
|
||||||
}
|
async function recordScanCost(record: ScanCostRecord): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
|
await db.insert(scanCosts).values({
|
||||||
if (!res.ok) {
|
subscriptionId: record.subscriptionId,
|
||||||
recordFailure(name);
|
source: record.source,
|
||||||
console.warn(`[darkwatch] ${name} returned ${res.status}`);
|
identifier: record.identifier,
|
||||||
return null;
|
apiCalls: record.apiCalls,
|
||||||
}
|
estimatedCost: record.estimatedCost,
|
||||||
recordSuccess(name);
|
cacheHits: record.cacheHits,
|
||||||
return res;
|
scanDurationMs: record.scanDurationMs,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
recordFailure(name);
|
console.error("[darkwatch] Failed to record scan cost:", err);
|
||||||
console.error(`[darkwatch] ${name} error:`, err);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unified exposure normalization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes raw exposure findings from any source into the internal ScanResult schema.
|
||||||
|
* This ensures consistent downstream processing regardless of API source.
|
||||||
|
*/
|
||||||
|
export function processScanResult(
|
||||||
|
source: "hibp" | "securityTrails" | "censys" | "shodan",
|
||||||
|
rawExposure: SecurityTrailsExposure | CensysExposure | ShodanExposure,
|
||||||
|
identifier: string,
|
||||||
|
): ScanResult {
|
||||||
|
const baseMetadata = {
|
||||||
|
source,
|
||||||
|
exposureType: (rawExposure as any).type,
|
||||||
|
detail: (rawExposure as any).detail,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dataType: ScanResult["dataType"] = "domain";
|
||||||
|
let scanIdentifier = identifier;
|
||||||
|
let extraMetadata: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
switch (source) {
|
||||||
|
case "securityTrails": {
|
||||||
|
const st = rawExposure as SecurityTrailsExposure;
|
||||||
|
if (st.subdomain) {
|
||||||
|
scanIdentifier = st.subdomain;
|
||||||
|
extraMetadata = { subdomain: st.subdomain, recordType: st.recordType, value: st.value };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "censys": {
|
||||||
|
const ce = rawExposure as CensysExposure;
|
||||||
|
if (ce.ip) {
|
||||||
|
scanIdentifier = ce.ip;
|
||||||
|
extraMetadata = {
|
||||||
|
ip: ce.ip,
|
||||||
|
port: ce.port,
|
||||||
|
service: ce.service,
|
||||||
|
vulnerabilityIds: ce.vulnerabilityIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "shodan": {
|
||||||
|
const se = rawExposure as ShodanExposure;
|
||||||
|
if (se.ip) {
|
||||||
|
scanIdentifier = se.ip;
|
||||||
|
extraMetadata = {
|
||||||
|
ip: se.ip,
|
||||||
|
port: se.port,
|
||||||
|
service: se.service,
|
||||||
|
vulns: se.vulns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
dataType,
|
||||||
|
identifier: scanIdentifier,
|
||||||
|
identifierHash: hashValue(scanIdentifier),
|
||||||
|
metadata: { ...baseMetadata, ...extraMetadata },
|
||||||
|
detectedAt: new Date(),
|
||||||
|
severity: rawExposure.severity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hash helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function hashValue(value: string): string {
|
function hashValue(value: string): string {
|
||||||
return createHash("sha256").update(value.toLowerCase().trim()).digest("hex");
|
return createHash("sha256")
|
||||||
|
.update(value.toLowerCase().trim())
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tier check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function canUseSource(tier: string, source: string): boolean {
|
||||||
|
const allowed = TIER_SOURCES[tier] ?? TIER_SOURCES.basic;
|
||||||
|
return allowed.has(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HIBP scan
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function scanHIBP(email: string, options: ScanOptions): Promise<ScanResult[]> {
|
||||||
|
if (!canUseSource(options.tier, "hibp")) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanHIBP(email: string): Promise<ScanResult[]> {
|
|
||||||
const apiKey = process.env.HIBP_API_KEY;
|
const apiKey = process.env.HIBP_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.warn("[darkwatch] HIBP_API_KEY not set, skipping HIBP scan");
|
console.warn("[darkwatch] HIBP_API_KEY not set, skipping HIBP scan");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const res = await fetchWithCircuit(
|
|
||||||
"hibp",
|
const start = Date.now();
|
||||||
`https://haveibeenpwned.com/api/v3/breachedaccount/${encodeURIComponent(email)}?truncateResponse=false`,
|
let apiCalls = 0;
|
||||||
{ "hibp-api-key": apiKey, "user-agent": "Kordant-DarkWatch" },
|
let cacheHits = 0;
|
||||||
);
|
|
||||||
if (!res) return [];
|
try {
|
||||||
const breaches = await res.json() as Array<{ Name: string; BreachDate: string; DataClasses: string[]; Description: string }>;
|
const hibp = getHIBPClient();
|
||||||
return breaches.map((b) => ({
|
const breaches = await hibp.checkEmail(email);
|
||||||
|
apiCalls = 1;
|
||||||
|
|
||||||
|
const results: ScanResult[] = breaches.map((b) => ({
|
||||||
source: "hibp" as const,
|
source: "hibp" as const,
|
||||||
dataType: "email" as const,
|
dataType: "email" as const,
|
||||||
identifier: email,
|
identifier: email,
|
||||||
identifierHash: hashValue(email),
|
identifierHash: hashValue(email),
|
||||||
metadata: { breachName: b.Name, breachDate: b.BreachDate, dataClasses: b.DataClasses, description: b.Description },
|
metadata: {
|
||||||
detectedAt: new Date(b.BreachDate),
|
breachName: b.breachName,
|
||||||
severity: "critical" as const,
|
breachDate: b.breachDate,
|
||||||
|
dataClasses: b.dataClasses,
|
||||||
|
description: b.description,
|
||||||
|
domain: b.domain,
|
||||||
|
pwnCount: b.pwnCount,
|
||||||
|
isVerified: b.isVerified,
|
||||||
|
isSensitive: b.isSensitive,
|
||||||
|
isSpamList: b.isSpamList,
|
||||||
|
isMalware: b.isMalware,
|
||||||
|
},
|
||||||
|
detectedAt: new Date(b.breachDate),
|
||||||
|
severity: b.severity,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "hibp",
|
||||||
|
identifier: email,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.hibp,
|
||||||
|
cacheHits,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[darkwatch] HIBP scan error:", err);
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "hibp",
|
||||||
|
identifier: email,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.hibp,
|
||||||
|
cacheHits,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SecurityTrails scan — domain/subdomain enumeration + exposure analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function scanSecurityTrails(domain: string, options: ScanOptions): Promise<ScanResult[]> {
|
||||||
|
if (!canUseSource(options.tier, "securityTrails")) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanSecurityTrails(identifier: string): Promise<ScanResult[]> {
|
|
||||||
const apiKey = process.env.SECURITYTRAILS_API_KEY;
|
const apiKey = process.env.SECURITYTRAILS_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.warn("[darkwatch] SECURITYTRAILS_API_KEY not set, skipping");
|
console.warn("[darkwatch] SECURITYTRAILS_API_KEY not set, skipping");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const domain = identifier.includes("@") ? identifier.split("@")[1] : identifier;
|
|
||||||
const res = await fetchWithCircuit(
|
const start = Date.now();
|
||||||
"securitytrails",
|
let apiCalls = 0;
|
||||||
`https://api.securitytrails.com/v1/domain/${encodeURIComponent(domain)}/subdomains`,
|
let cacheHits = 0;
|
||||||
{ APIKEY: apiKey },
|
|
||||||
);
|
try {
|
||||||
if (!res) return [];
|
const st = getSecurityTrailsClient();
|
||||||
const data = await res.json() as { subdomains: string[] };
|
const domainInfo = await st.getDomainInfo(domain);
|
||||||
return (data.subdomains ?? []).slice(0, 20).map((sub) => ({
|
if (!domainInfo) return [];
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
// Analyze exposures from domain info
|
||||||
|
const stExposures = st.analyzeExposures(domainInfo);
|
||||||
|
|
||||||
|
// Also create results for subdomain enumeration
|
||||||
|
const subdomainResults: ScanResult[] = (domainInfo.subdomains ?? [])
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((sub) => ({
|
||||||
source: "securityTrails" as const,
|
source: "securityTrails" as const,
|
||||||
dataType: "domain" as const,
|
dataType: "domain" as const,
|
||||||
identifier: `${sub}.${domain}`,
|
identifier: `${sub}.${domain}`,
|
||||||
identifierHash: hashValue(`${sub}.${domain}`),
|
identifierHash: hashValue(`${sub}.${domain}`),
|
||||||
metadata: { subdomain: sub, domain },
|
metadata: {
|
||||||
|
source: "securityTrails",
|
||||||
|
exposureType: "subdomain_discovery",
|
||||||
|
detail: `Subdomain discovered: ${sub}.${domain}`,
|
||||||
|
subdomain: sub,
|
||||||
|
domain,
|
||||||
|
},
|
||||||
detectedAt: new Date(),
|
detectedAt: new Date(),
|
||||||
severity: "info" as const,
|
severity: "info" as const,
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
export async function scanCensys(query: string): Promise<ScanResult[]> {
|
// Normalize analyzed exposures
|
||||||
const apiKey = process.env.CENSYS_API_KEY;
|
const exposureResults: ScanResult[] = stExposures.map((exp) =>
|
||||||
if (!apiKey) {
|
processScanResult("securityTrails", exp, domain),
|
||||||
console.warn("[darkwatch] CENSYS_API_KEY not set, skipping");
|
);
|
||||||
|
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "securityTrails",
|
||||||
|
identifier: domain,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.securityTrails,
|
||||||
|
cacheHits,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...subdomainResults, ...exposureResults];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[darkwatch] SecurityTrails scan error:", err);
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "securityTrails",
|
||||||
|
identifier: domain,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.securityTrails,
|
||||||
|
cacheHits,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const res = await fetchWithCircuit(
|
|
||||||
"censys",
|
|
||||||
`https://search.censys.io/api/v2/hosts/search?q=${encodeURIComponent(query)}&per_page=10`,
|
|
||||||
{ Authorization: `Bearer ${apiKey}` },
|
|
||||||
);
|
|
||||||
if (!res) return [];
|
|
||||||
const data = await res.json() as { result?: { hits?: Array<{ ip: string; services?: Array<{ service_name: string; port: number }> }> } };
|
|
||||||
const hits = data.result?.hits ?? [];
|
|
||||||
return hits.map((h) => ({
|
|
||||||
source: "censys" as const,
|
|
||||||
dataType: "domain" as const,
|
|
||||||
identifier: h.ip,
|
|
||||||
identifierHash: hashValue(h.ip),
|
|
||||||
metadata: { ip: h.ip, services: h.services },
|
|
||||||
detectedAt: new Date(),
|
|
||||||
severity: "warning" as const,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanShodan(query: string): Promise<ScanResult[]> {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Censys scan — host search + certificate analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function scanCensys(identifier: string, options: ScanOptions): Promise<ScanResult[]> {
|
||||||
|
if (!canUseSource(options.tier, "censys")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiId = process.env.CENSYS_API_ID;
|
||||||
|
const apiSecret = process.env.CENSYS_API_SECRET;
|
||||||
|
if (!apiId || !apiSecret) {
|
||||||
|
console.warn("[darkwatch] CENSYS_API_ID/SECRET not set, skipping");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let apiCalls = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const censys = getCensysClient();
|
||||||
|
|
||||||
|
// Determine if identifier is an IP or domain
|
||||||
|
const isIp = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(identifier);
|
||||||
|
|
||||||
|
// Search hosts
|
||||||
|
const hostResults = await censys.searchHosts(isIp ? `ip:${identifier}` : identifier);
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
const hostExposureResults: ScanResult[] = [];
|
||||||
|
for (const host of hostResults.hosts) {
|
||||||
|
// Analyze host for exposures
|
||||||
|
const exposures = censys.analyzeHostExposures(host);
|
||||||
|
for (const exp of exposures) {
|
||||||
|
hostExposureResults.push(processScanResult("censys", exp, identifier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If domain, also check certificates
|
||||||
|
let certExposureResults: ScanResult[] = [];
|
||||||
|
if (!isIp) {
|
||||||
|
const certResults = await censys.getCertificates(identifier);
|
||||||
|
apiCalls++;
|
||||||
|
if (certResults.certificates.length) {
|
||||||
|
const certExposures = censys.analyzeCertificateExposures(certResults.certificates);
|
||||||
|
certExposureResults = certExposures.map((exp) =>
|
||||||
|
processScanResult("censys", exp, identifier),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "censys",
|
||||||
|
identifier,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.censys,
|
||||||
|
cacheHits: 0,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...hostExposureResults, ...certExposureResults];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[darkwatch] Censys scan error:", err);
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "censys",
|
||||||
|
identifier,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.censys,
|
||||||
|
cacheHits: 0,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shodan scan — device/service exposure analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function scanShodan(identifier: string, options: ScanOptions): Promise<ScanResult[]> {
|
||||||
|
if (!canUseSource(options.tier, "shodan")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = process.env.SHODAN_API_KEY;
|
const apiKey = process.env.SHODAN_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.warn("[darkwatch] SHODAN_API_KEY not set, skipping");
|
console.warn("[darkwatch] SHODAN_API_KEY not set, skipping");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const res = await fetchWithCircuit(
|
|
||||||
"shodan",
|
const start = Date.now();
|
||||||
`https://api.shodan.io/shodan/host/search?key=${apiKey}&query=${encodeURIComponent(query)}&limit=10`,
|
let apiCalls = 0;
|
||||||
{},
|
|
||||||
);
|
try {
|
||||||
if (!res) return [];
|
const shodan = getShodanClient();
|
||||||
const data = await res.json() as { matches?: Array<{ ip_str: string; port: number; org?: string; hostnames?: string[] }> };
|
|
||||||
const matches = data.matches ?? [];
|
// Determine if identifier is an IP or domain
|
||||||
return matches.map((m) => ({
|
const isIp = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(identifier);
|
||||||
source: "shodan" as const,
|
|
||||||
dataType: "domain" as const,
|
const results: ScanResult[] = [];
|
||||||
identifier: m.ip_str,
|
|
||||||
identifierHash: hashValue(m.ip_str),
|
if (isIp) {
|
||||||
metadata: { ip: m.ip_str, port: m.port, org: m.org, hostnames: m.hostnames },
|
// Direct IP lookup
|
||||||
detectedAt: new Date(),
|
const host = await shodan.host(identifier);
|
||||||
severity: "warning" as const,
|
apiCalls++;
|
||||||
}));
|
if (host) {
|
||||||
|
const exposures = shodan.analyzeHostExposures(host);
|
||||||
|
for (const exp of exposures) {
|
||||||
|
results.push(processScanResult("shodan", exp, identifier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Domain search — use count first for cost efficiency
|
||||||
|
const countResult = await shodan.count(identifier);
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
if (countResult.total > 0) {
|
||||||
|
const searchResult = await shodan.search(identifier);
|
||||||
|
apiCalls++;
|
||||||
|
|
||||||
|
for (const host of searchResult.matches) {
|
||||||
|
const exposures = shodan.analyzeHostExposures(host);
|
||||||
|
for (const exp of exposures) {
|
||||||
|
results.push(processScanResult("shodan", exp, host.ip_str ?? identifier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanForums(identifier: string): Promise<ScanResult[]> {
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "shodan",
|
||||||
|
identifier,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.shodan,
|
||||||
|
cacheHits: 0,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[darkwatch] Shodan scan error:", err);
|
||||||
|
await recordScanCost({
|
||||||
|
subscriptionId: options.subscriptionId,
|
||||||
|
source: "shodan",
|
||||||
|
identifier,
|
||||||
|
apiCalls,
|
||||||
|
estimatedCost: apiCalls * COST_PER_REQUEST.shodan,
|
||||||
|
cacheHits: 0,
|
||||||
|
scanDurationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Forum scan (placeholder for future DarkOwl integration)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function scanForums(
|
||||||
|
identifier: string,
|
||||||
|
options: ScanOptions,
|
||||||
|
): Promise<ScanResult[]> {
|
||||||
const forumEnabled = process.env.DARKWEB_FORUM_ENABLED;
|
const forumEnabled = process.env.DARKWEB_FORUM_ENABLED;
|
||||||
if (!forumEnabled || forumEnabled !== "true") {
|
if (!forumEnabled || forumEnabled !== "true") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{
|
return [
|
||||||
|
{
|
||||||
source: "darkWebForum" as const,
|
source: "darkWebForum" as const,
|
||||||
dataType: (identifier.includes("@") ? "email" : "domain") as "email" | "domain",
|
dataType: (identifier.includes("@") ? "email" : "domain") as "email" | "domain",
|
||||||
identifier,
|
identifier,
|
||||||
@@ -182,7 +495,65 @@ export async function scanForums(identifier: string): Promise<ScanResult[]> {
|
|||||||
metadata: { note: "Forum scraping placeholder", identifier },
|
metadata: { note: "Forum scraping placeholder", identifier },
|
||||||
detectedAt: new Date(),
|
detectedAt: new Date(),
|
||||||
severity: "warning" as const,
|
severity: "warning" as const,
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ScanResult };
|
// ---------------------------------------------------------------------------
|
||||||
|
// Combined scan — runs all applicable sources for an identifier
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function scanIdentifier(
|
||||||
|
identifier: string,
|
||||||
|
dataType: "email" | "domain" | "phoneNumber" | "ssn" | "address",
|
||||||
|
options: ScanOptions,
|
||||||
|
): Promise<ScanResult[]> {
|
||||||
|
const allResults: ScanResult[] = [];
|
||||||
|
|
||||||
|
// HIBP: email only
|
||||||
|
if (dataType === "email") {
|
||||||
|
allResults.push(...await scanHIBP(identifier, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityTrails, Censys, Shodan: domain and IP only
|
||||||
|
if (dataType === "domain") {
|
||||||
|
// Extract domain if email
|
||||||
|
const domain = identifier.includes("@") ? identifier.split("@")[1] : identifier;
|
||||||
|
|
||||||
|
if (canUseSource(options.tier, "securityTrails")) {
|
||||||
|
allResults.push(...await scanSecurityTrails(domain, options));
|
||||||
|
}
|
||||||
|
if (canUseSource(options.tier, "censys")) {
|
||||||
|
allResults.push(...await scanCensys(domain, options));
|
||||||
|
}
|
||||||
|
if (canUseSource(options.tier, "shodan")) {
|
||||||
|
allResults.push(...await scanShodan(domain, options));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backwards-compatible exports (for existing callers without options)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @deprecated Use scanHIBP(email, options) instead */
|
||||||
|
export async function scanHIBPLegacy(email: string): Promise<ScanResult[]> {
|
||||||
|
return scanHIBP(email, { subscriptionId: "legacy", tier: "basic" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use scanSecurityTrails(domain, options) instead */
|
||||||
|
export async function scanSecurityTrailsLegacy(identifier: string): Promise<ScanResult[]> {
|
||||||
|
return scanSecurityTrails(identifier, { subscriptionId: "legacy", tier: "premium" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use scanCensys(query, options) instead */
|
||||||
|
export async function scanCensysLegacy(query: string): Promise<ScanResult[]> {
|
||||||
|
return scanCensys(query, { subscriptionId: "legacy", tier: "premium" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use scanShodan(query, options) instead */
|
||||||
|
export async function scanShodanLegacy(query: string): Promise<ScanResult[]> {
|
||||||
|
return scanShodan(query, { subscriptionId: "legacy", tier: "premium" });
|
||||||
|
}
|
||||||
|
|||||||
181
web/src/server/services/darkwatch/scan.metrics.test.ts
Normal file
181
web/src/server/services/darkwatch/scan.metrics.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock db
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("~/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: mockSelect,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
eq: vi.fn((col: any) => ({ col })),
|
||||||
|
and: vi.fn((...conds: any[]) => ({ conds })),
|
||||||
|
desc: vi.fn((col: any) => ({ col })),
|
||||||
|
count: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("scan metrics and threat score", () => {
|
||||||
|
describe("threat score calculation", () => {
|
||||||
|
it("should return 0 for no exposures", () => {
|
||||||
|
const exposures: any[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
const exposureCountScore = Math.min(30, Math.log2(exposures.length + 1) * 10);
|
||||||
|
score += exposureCountScore;
|
||||||
|
|
||||||
|
expect(score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should score based on severity", () => {
|
||||||
|
const exposures = [
|
||||||
|
{ severity: "critical" },
|
||||||
|
{ severity: "warning" },
|
||||||
|
{ severity: "info" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
const exposureCountScore = Math.min(30, Math.log2(exposures.length + 1) * 10);
|
||||||
|
score += exposureCountScore;
|
||||||
|
|
||||||
|
for (const exp of exposures) {
|
||||||
|
switch (exp.severity) {
|
||||||
|
case "critical": score += 15; break;
|
||||||
|
case "warning": score += 8; break;
|
||||||
|
case "info": score += 3; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 exposures: log2(4) * 10 = 20, capped at 30
|
||||||
|
// critical: 15, warning: 8, info: 3
|
||||||
|
expect(score).toBe(46); // 20 + 15 + 8 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cap at 100", () => {
|
||||||
|
const exposures = Array(20).fill(null).map(() => ({ severity: "critical" }));
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
const exposureCountScore = Math.min(30, Math.log2(exposures.length + 1) * 10);
|
||||||
|
score += exposureCountScore;
|
||||||
|
|
||||||
|
for (const exp of exposures) {
|
||||||
|
score += 15; // critical
|
||||||
|
}
|
||||||
|
|
||||||
|
const capped = Math.min(100, Math.round(score));
|
||||||
|
expect(capped).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should give diminishing returns for exposure count", () => {
|
||||||
|
const score1 = Math.min(30, Math.log2(1 + 1) * 10); // 1 exposure
|
||||||
|
const score10 = Math.min(30, Math.log2(10 + 1) * 10); // 10 exposures
|
||||||
|
const score100 = Math.min(30, Math.log2(100 + 1) * 10); // 100 exposures
|
||||||
|
|
||||||
|
expect(score1).toBe(10);
|
||||||
|
expect(score10).toBe(30); // capped
|
||||||
|
expect(score100).toBe(30); // capped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scan progress percentage", () => {
|
||||||
|
it("should calculate correct percentages", () => {
|
||||||
|
const cases = [
|
||||||
|
{ completed: 0, total: 4, expected: 0 },
|
||||||
|
{ completed: 1, total: 4, expected: 25 },
|
||||||
|
{ completed: 2, total: 4, expected: 50 },
|
||||||
|
{ completed: 3, total: 4, expected: 75 },
|
||||||
|
{ completed: 4, total: 4, expected: 100 },
|
||||||
|
{ completed: 0, total: 1, expected: 0 },
|
||||||
|
{ completed: 1, total: 1, expected: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { completed, total, expected } of cases) {
|
||||||
|
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
expect(percentage).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cooldown period calculations", () => {
|
||||||
|
it("should calculate cooldown end correctly", () => {
|
||||||
|
const lastSent = new Date(Date.now() - 5 * 60 * 60 * 1000); // 5h ago
|
||||||
|
const cooldownHours = 24;
|
||||||
|
const cooldownEnd = lastSent.getTime() + cooldownHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const remaining = cooldownEnd - Date.now();
|
||||||
|
const remainingHours = Math.ceil(remaining / (60 * 60 * 1000));
|
||||||
|
|
||||||
|
expect(remainingHours).toBeGreaterThan(0);
|
||||||
|
expect(remainingHours).toBeLessThanOrEqual(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle expired cooldown", () => {
|
||||||
|
const lastSent = new Date(Date.now() - 48 * 60 * 60 * 1000); // 48h ago
|
||||||
|
const cooldownHours = 24;
|
||||||
|
const cooldownEnd = lastSent.getTime() + cooldownHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const remaining = cooldownEnd - Date.now();
|
||||||
|
expect(remaining).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("severity escalation check", () => {
|
||||||
|
const SEVERITY_ORDER: Record<string, number> = {
|
||||||
|
info: 0,
|
||||||
|
warning: 1,
|
||||||
|
critical: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should detect severity escalation", () => {
|
||||||
|
const lastLevel = SEVERITY_ORDER["warning"];
|
||||||
|
const newLevel = SEVERITY_ORDER["critical"];
|
||||||
|
expect(newLevel > lastLevel).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not escalate at same severity", () => {
|
||||||
|
const lastLevel = SEVERITY_ORDER["warning"];
|
||||||
|
const newLevel = SEVERITY_ORDER["warning"];
|
||||||
|
expect(newLevel > lastLevel).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not escalate on downgrade", () => {
|
||||||
|
const lastLevel = SEVERITY_ORDER["critical"];
|
||||||
|
const newLevel = SEVERITY_ORDER["info"];
|
||||||
|
expect(newLevel > lastLevel).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("digest scheduling", () => {
|
||||||
|
it("should calculate next daily digest date", () => {
|
||||||
|
const dailyHour = 9;
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setUTCHours(dailyHour, 0, 0, 0);
|
||||||
|
|
||||||
|
if (next.getTime() <= now.getTime()) {
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
expect(next.getUTCHours()).toBe(dailyHour);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate next weekly digest date", () => {
|
||||||
|
const weeklyDay = 0; // Sunday
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setUTCHours(9, 0, 0, 0);
|
||||||
|
const currentDay = next.getUTCDay();
|
||||||
|
const daysUntilTarget = (weeklyDay - currentDay + 7) % 7;
|
||||||
|
|
||||||
|
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
|
||||||
|
next.setUTCDate(next.getUTCDate() + 7);
|
||||||
|
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
|
||||||
|
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
340
web/src/server/services/darkwatch/securitytrails.client.test.ts
Normal file
340
web/src/server/services/darkwatch/securitytrails.client.test.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
SecurityTrailsClient,
|
||||||
|
resetSecurityTrailsClient,
|
||||||
|
getSecurityTrailsClient,
|
||||||
|
} from "./securitytrails.client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SecurityTrailsClient — unit tests with mocked fetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("SecurityTrailsClient", () => {
|
||||||
|
const apiKey = "test-st-key";
|
||||||
|
let client: SecurityTrailsClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetSecurityTrailsClient();
|
||||||
|
client = new SecurityTrailsClient(apiKey, 100); // high rate limit for tests
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getSubdomains
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getSubdomains", () => {
|
||||||
|
it("returns parsed subdomains", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
subdomains: ["www", "mail", "api", "staging"],
|
||||||
|
shareid: "abc123",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getSubdomains("example.com");
|
||||||
|
expect(result.subdomains).toEqual(["www", "mail", "api", "staging"]);
|
||||||
|
expect(result.shareid).toBe("abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty subdomain list", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ subdomains: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getSubdomains("empty.com");
|
||||||
|
expect(result.subdomains).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends correct API key header", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ subdomains: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.getSubdomains("test.com");
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.securitytrails.com/v1/domain/test.com/subdomains",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ APIKEY: apiKey }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 429 rate limit", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 429 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.getSubdomains("test.com")).rejects.toThrow(
|
||||||
|
"SecurityTrails rate limit exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 403 auth failure", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 403 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.getSubdomains("test.com")).rejects.toThrow(
|
||||||
|
"SecurityTrails API key invalid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens circuit breaker after 3 consecutive failures", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.getSubdomains("test.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(client.getSubdomains("test.com")).rejects.toThrow(
|
||||||
|
"SecurityTrails circuit breaker is open",
|
||||||
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getDnsRecords (via getDomainInfo)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getDomainInfo", () => {
|
||||||
|
it("returns combined domain info from DNS + WHOIS + subdomains", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const dnsResponse = {
|
||||||
|
records: { A: ["93.184.216.34"], MX: [{ preference: 10, value: "mail.example.com" }] },
|
||||||
|
shareid: "dns123",
|
||||||
|
};
|
||||||
|
const whoisResponse = {
|
||||||
|
registrar: "Example Registrar",
|
||||||
|
creation_date: "1995-08-14",
|
||||||
|
expiration_date: "2025-08-13",
|
||||||
|
nameservers: ["ns1.example.com", "ns2.example.com"],
|
||||||
|
};
|
||||||
|
const subResponse = {
|
||||||
|
subdomains: ["www", "mail"],
|
||||||
|
shareid: "sub123",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(dnsResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(whoisResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(subResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getDomainInfo("example.com");
|
||||||
|
expect(result?.domain).toBe("example.com");
|
||||||
|
expect(result?.dnsRecords).toHaveLength(2);
|
||||||
|
expect(result?.dnsRecords[0].recordType).toBe("A");
|
||||||
|
expect(result?.dnsRecords[1].recordType).toBe("MX");
|
||||||
|
expect(result?.whois.registrar).toBe("Example Registrar");
|
||||||
|
expect(result?.subdomains).toEqual(["www", "mail"]);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partial failures gracefully with allSettled", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
// DNS succeeds, WHOIS fails, subdomains succeeds
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ records: { A: ["1.2.3.4"] } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("WHOIS timeout"));
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ subdomains: ["www"] }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getDomainInfo("example.com");
|
||||||
|
expect(result?.dnsRecords).toHaveLength(1);
|
||||||
|
expect(result?.subdomains).toEqual(["www"]);
|
||||||
|
// WHOIS should be empty on failure
|
||||||
|
expect(result?.whois).toEqual({});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getHistory
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getHistory", () => {
|
||||||
|
it("parses history entries with change types", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
history: [
|
||||||
|
{ date: "2024-01-01", type: "A", old: { type: "A", value: "1.2.3.4" }, new: { type: "A", value: "5.6.7.8" } },
|
||||||
|
{ date: "2024-02-01", type: "MX", new: { type: "MX", value: "mail.new.com" } },
|
||||||
|
{ date: "2024-03-01", type: "TXT", old: { type: "TXT", value: "old-spam" } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.getHistory("example.com");
|
||||||
|
expect(result.history).toHaveLength(3);
|
||||||
|
expect(result.history[0].changeType).toBe("changed");
|
||||||
|
expect(result.history[1].changeType).toBe("added");
|
||||||
|
expect(result.history[2].changeType).toBe("removed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeExposures
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("analyzeExposures", () => {
|
||||||
|
it("detects large subdomain attack surface", () => {
|
||||||
|
const manySubs = Array.from({ length: 100 }, (_, i) => `sub${i}`);
|
||||||
|
const domainInfo = {
|
||||||
|
domain: "example.com",
|
||||||
|
dnsRecords: [],
|
||||||
|
whois: {},
|
||||||
|
subdomains: manySubs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeExposures(domainInfo);
|
||||||
|
const subExposure = exposures.find((e) => e.type === "subdomain_exposure");
|
||||||
|
expect(subExposure).toBeDefined();
|
||||||
|
expect(subExposure?.severity).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects SPF without DMARC", () => {
|
||||||
|
const domainInfo = {
|
||||||
|
domain: "example.com",
|
||||||
|
dnsRecords: [
|
||||||
|
{ recordType: "TXT", value: "v=spf1 include:_spf.google.com ~all" },
|
||||||
|
],
|
||||||
|
whois: {},
|
||||||
|
subdomains: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeExposures(domainInfo);
|
||||||
|
const dnsExp = exposures.find((e) => e.type === "dns_misconfiguration");
|
||||||
|
expect(dnsExp).toBeDefined();
|
||||||
|
expect(dnsExp?.detail).toContain("DMARC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag when both SPF and DMARC present", () => {
|
||||||
|
const domainInfo = {
|
||||||
|
domain: "example.com",
|
||||||
|
dnsRecords: [
|
||||||
|
{ recordType: "TXT", value: "v=spf1 include:_spf.google.com ~all" },
|
||||||
|
{ recordType: "TXT", value: "v=DMARC1; p=reject" },
|
||||||
|
],
|
||||||
|
whois: {},
|
||||||
|
subdomains: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeExposures(domainInfo);
|
||||||
|
const dnsExp = exposures.find((e) => e.type === "dns_misconfiguration");
|
||||||
|
expect(dnsExp).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects domain expiring soon", () => {
|
||||||
|
const soon = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const domainInfo = {
|
||||||
|
domain: "example.com",
|
||||||
|
dnsRecords: [],
|
||||||
|
whois: { expirationDate: soon },
|
||||||
|
subdomains: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeExposures(domainInfo);
|
||||||
|
const hijackExp = exposures.find((e) => e.type === "domain_hijack_risk");
|
||||||
|
expect(hijackExp).toBeDefined();
|
||||||
|
expect(hijackExp?.severity).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks domain expiring in < 7 days as critical", () => {
|
||||||
|
const verySoon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const domainInfo = {
|
||||||
|
domain: "example.com",
|
||||||
|
dnsRecords: [],
|
||||||
|
whois: { expirationDate: verySoon },
|
||||||
|
subdomains: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeExposures(domainInfo);
|
||||||
|
const hijackExp = exposures.find((e) => e.type === "domain_hijack_risk");
|
||||||
|
expect(hijackExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no exposures for healthy domain", () => {
|
||||||
|
const farFuture = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const domainInfo = {
|
||||||
|
domain: "example.com",
|
||||||
|
dnsRecords: [
|
||||||
|
{ recordType: "TXT", value: "v=DMARC1; p=reject" },
|
||||||
|
],
|
||||||
|
whois: { expirationDate: farFuture },
|
||||||
|
subdomains: ["www"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeExposures(domainInfo);
|
||||||
|
expect(exposures.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Singleton
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("singleton", () => {
|
||||||
|
it("creates client from env vars", () => {
|
||||||
|
process.env.SECURITYTRAILS_API_KEY = "env-key";
|
||||||
|
resetSecurityTrailsClient();
|
||||||
|
const c = getSecurityTrailsClient();
|
||||||
|
expect(c).toBeInstanceOf(SecurityTrailsClient);
|
||||||
|
delete process.env.SECURITYTRAILS_API_KEY;
|
||||||
|
resetSecurityTrailsClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when env var missing", () => {
|
||||||
|
delete process.env.SECURITYTRAILS_API_KEY;
|
||||||
|
resetSecurityTrailsClient();
|
||||||
|
expect(() => getSecurityTrailsClient()).toThrow("SECURITYTRAILS_API_KEY");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
453
web/src/server/services/darkwatch/securitytrails.client.ts
Normal file
453
web/src/server/services/darkwatch/securitytrails.client.ts
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { get, set } from "~/server/lib/cache";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DnsRecord {
|
||||||
|
recordType: string;
|
||||||
|
value: string;
|
||||||
|
firstSeen?: string;
|
||||||
|
lastSeen?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhoisRecord {
|
||||||
|
registrar?: string;
|
||||||
|
registrantName?: string;
|
||||||
|
registrantOrg?: string;
|
||||||
|
creationDate?: string;
|
||||||
|
expirationDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
nameServers?: string[];
|
||||||
|
status?: string[];
|
||||||
|
raw?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubdomainRecord {
|
||||||
|
subdomain: string;
|
||||||
|
domain: string;
|
||||||
|
fullDomain: string;
|
||||||
|
ipAddresses?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
date: string;
|
||||||
|
recordType: string;
|
||||||
|
changeType: "added" | "removed" | "changed";
|
||||||
|
oldValue?: string;
|
||||||
|
newValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityTrailsDomainInfo {
|
||||||
|
domain: string;
|
||||||
|
dnsRecords: DnsRecord[];
|
||||||
|
whois: WhoisRecord;
|
||||||
|
subdomains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityTrailsExposure {
|
||||||
|
type: "subdomain_exposure" | "dns_misconfiguration" | "domain_hijack_risk" | "open_resolver" | "wildcard_dns";
|
||||||
|
severity: "info" | "warning" | "critical";
|
||||||
|
detail: string;
|
||||||
|
subdomain?: string;
|
||||||
|
recordType?: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawDnsResponse {
|
||||||
|
records: {
|
||||||
|
A?: string[];
|
||||||
|
AAAA?: string[];
|
||||||
|
MX?: Array<{ preference: number; value: string }>;
|
||||||
|
NS?: string[];
|
||||||
|
TXT?: string[];
|
||||||
|
CNAME?: string[];
|
||||||
|
SOA?: {
|
||||||
|
mname: string;
|
||||||
|
rname: string;
|
||||||
|
serial: number;
|
||||||
|
refresh: number;
|
||||||
|
retry: number;
|
||||||
|
expire: number;
|
||||||
|
minimum: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
shareid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSubdomainsResponse {
|
||||||
|
subdomains: string[];
|
||||||
|
shareid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawHistoryResponse {
|
||||||
|
history: Array<{
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
old?: { type: string; value: string };
|
||||||
|
new?: { type: string; value: string };
|
||||||
|
}>;
|
||||||
|
shareid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawWhoisResponse {
|
||||||
|
registrar?: string;
|
||||||
|
registrant_name?: string;
|
||||||
|
registrant_org?: string;
|
||||||
|
creation_date?: string;
|
||||||
|
expiration_date?: string;
|
||||||
|
updated_date?: string;
|
||||||
|
nameservers?: string[];
|
||||||
|
status?: string[];
|
||||||
|
raw?: string;
|
||||||
|
shareid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SecurityTrails API Client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CACHE_PREFIX = "st";
|
||||||
|
const DNS_CACHE_TTL = 86_400; // 24 hours
|
||||||
|
const WHOIS_CACHE_TTL = 86_400; // 24 hours
|
||||||
|
const HISTORY_CACHE_TTL = 43_200; // 12 hours
|
||||||
|
|
||||||
|
export class SecurityTrailsClient {
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly baseUrl = "https://api.securitytrails.com/v1";
|
||||||
|
|
||||||
|
// Circuit breaker state
|
||||||
|
private circuitFailures = 0;
|
||||||
|
private circuitLastFailure = 0;
|
||||||
|
private circuitIsOpen = false;
|
||||||
|
private readonly circuitThreshold = 3;
|
||||||
|
private readonly circuitResetMs = 60_000;
|
||||||
|
|
||||||
|
// Rate limiting (10 req/sec = 100ms interval)
|
||||||
|
private lastRequestTime = 0;
|
||||||
|
private readonly minRequestIntervalMs: number;
|
||||||
|
|
||||||
|
constructor(apiKey: string, requestsPerSecond = 10) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.minRequestIntervalMs = 1000 / Math.max(requestsPerSecond, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Circuit breaker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private isCircuitOpen(): boolean {
|
||||||
|
if (!this.circuitIsOpen) return false;
|
||||||
|
if (Date.now() - this.circuitLastFailure > this.circuitResetMs) {
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFailure(): void {
|
||||||
|
this.circuitFailures++;
|
||||||
|
this.circuitLastFailure = Date.now();
|
||||||
|
if (this.circuitFailures >= this.circuitThreshold) {
|
||||||
|
this.circuitIsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSuccess(): void {
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
this.circuitLastFailure = 0;
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rate limiter
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async waitForRateLimit(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastRequestTime;
|
||||||
|
if (elapsed < this.minRequestIntervalMs) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, this.minRequestIntervalMs - elapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.lastRequestTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HTTP helper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async request<T>(path: string): Promise<T | null> {
|
||||||
|
if (this.isCircuitOpen()) {
|
||||||
|
throw new Error("SecurityTrails circuit breaker is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForRateLimit();
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
APIKEY: this.apiKey,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("SecurityTrails rate limit exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("SecurityTrails API key invalid or insufficient permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(`SecurityTrails returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordSuccess();
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && (err.message.includes("circuit") || err.message.includes("rate limit"))) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.recordFailure();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getDomainInfo — WHOIS + DNS records + subdomains
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getDomainInfo(domain: string): Promise<SecurityTrailsDomainInfo | null> {
|
||||||
|
const [dnsData, whoisData, subdomainData] = await Promise.allSettled([
|
||||||
|
this.getDnsRecords(domain),
|
||||||
|
this.getWhois(domain),
|
||||||
|
this.getSubdomains(domain),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
dnsRecords: dnsData.status === "fulfilled" ? dnsData.value.records : [],
|
||||||
|
whois: whoisData.status === "fulfilled" ? whoisData.value : {},
|
||||||
|
subdomains: subdomainData.status === "fulfilled" ? subdomainData.value.subdomains : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getDnsRecords — A, AAAA, MX, NS, TXT, CNAME, SOA
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async getDnsRecords(domain: string): Promise<{ records: DnsRecord[]; shareid?: string }> {
|
||||||
|
const cacheKey = `dns:${createHash("sha256").update(domain.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<{ records: DnsRecord[]; shareid?: string }>(cacheKey, { prefix: CACHE_PREFIX, ttl: DNS_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await this.request<RawDnsResponse>(`/domain/${encodeURIComponent(domain)}`);
|
||||||
|
if (!data) return { records: [] };
|
||||||
|
|
||||||
|
const records: DnsRecord[] = [];
|
||||||
|
const raw = data.records;
|
||||||
|
|
||||||
|
for (const ip of raw.A ?? []) {
|
||||||
|
records.push({ recordType: "A", value: ip });
|
||||||
|
}
|
||||||
|
for (const ip of raw.AAAA ?? []) {
|
||||||
|
records.push({ recordType: "AAAA", value: ip });
|
||||||
|
}
|
||||||
|
for (const mx of raw.MX ?? []) {
|
||||||
|
records.push({ recordType: "MX", value: `${mx.preference} ${mx.value}` });
|
||||||
|
}
|
||||||
|
for (const ns of raw.NS ?? []) {
|
||||||
|
records.push({ recordType: "NS", value: ns });
|
||||||
|
}
|
||||||
|
for (const txt of raw.TXT ?? []) {
|
||||||
|
records.push({ recordType: "TXT", value: txt });
|
||||||
|
}
|
||||||
|
for (const cname of raw.CNAME ?? []) {
|
||||||
|
records.push({ recordType: "CNAME", value: cname });
|
||||||
|
}
|
||||||
|
if (raw.SOA) {
|
||||||
|
records.push({
|
||||||
|
recordType: "SOA",
|
||||||
|
value: `${raw.SOA.mname} ${raw.SOA.rname} ${raw.SOA.serial}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { records, shareid: data.shareid };
|
||||||
|
// Fire-and-forget cache write
|
||||||
|
set(cacheKey, result, { prefix: CACHE_PREFIX, ttl: DNS_CACHE_TTL }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getSubdomains — enumerate all subdomains
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getSubdomains(domain: string): Promise<{ subdomains: string[]; shareid?: string }> {
|
||||||
|
const cacheKey = `sub:${createHash("sha256").update(domain.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<{ subdomains: string[]; shareid?: string }>(cacheKey, { prefix: CACHE_PREFIX, ttl: DNS_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await this.request<RawSubdomainsResponse>(`/domain/${encodeURIComponent(domain)}/subdomains`);
|
||||||
|
if (!data) return { subdomains: [] };
|
||||||
|
|
||||||
|
const result = { subdomains: data.subdomains ?? [], shareid: data.shareid };
|
||||||
|
set(cacheKey, result, { prefix: CACHE_PREFIX, ttl: DNS_CACHE_TTL }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getHistory — historical DNS changes
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getHistory(domain: string): Promise<{ history: HistoryEntry[]; shareid?: string }> {
|
||||||
|
const cacheKey = `hist:${createHash("sha256").update(domain.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<{ history: HistoryEntry[]; shareid?: string }>(cacheKey, { prefix: CACHE_PREFIX, ttl: HISTORY_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await this.request<RawHistoryResponse>(`/domain/${encodeURIComponent(domain)}/history`);
|
||||||
|
if (!data) return { history: [] };
|
||||||
|
|
||||||
|
const history: HistoryEntry[] = (data.history ?? []).map((entry) => {
|
||||||
|
const changeType = entry.old && entry.new ? "changed" : entry.old ? "removed" : "added";
|
||||||
|
return {
|
||||||
|
date: entry.date,
|
||||||
|
recordType: entry.type,
|
||||||
|
changeType,
|
||||||
|
oldValue: entry.old?.value,
|
||||||
|
newValue: entry.new?.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = { history, shareid: data.shareid };
|
||||||
|
set(cacheKey, result, { prefix: CACHE_PREFIX, ttl: HISTORY_CACHE_TTL }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getWhois — domain registration info
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getWhois(domain: string): Promise<WhoisRecord> {
|
||||||
|
const cacheKey = `whois:${createHash("sha256").update(domain.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<WhoisRecord>(cacheKey, { prefix: CACHE_PREFIX, ttl: WHOIS_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await this.request<RawWhoisResponse>(`/domain/${encodeURIComponent(domain)}/whois`);
|
||||||
|
if (!data) return {};
|
||||||
|
|
||||||
|
const whois: WhoisRecord = {
|
||||||
|
registrar: data.registrar,
|
||||||
|
registrantName: data.registrant_name,
|
||||||
|
registrantOrg: data.registrant_org,
|
||||||
|
creationDate: data.creation_date,
|
||||||
|
expirationDate: data.expiration_date,
|
||||||
|
updatedDate: data.updated_date,
|
||||||
|
nameServers: data.nameservers,
|
||||||
|
status: data.status,
|
||||||
|
raw: data.raw,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(cacheKey, whois, { prefix: CACHE_PREFIX, ttl: WHOIS_CACHE_TTL }).catch(() => {});
|
||||||
|
return whois;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeExposures — heuristic analysis of DNS/WHOIS for security issues
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
analyzeExposures(domainInfo: SecurityTrailsDomainInfo): SecurityTrailsExposure[] {
|
||||||
|
const exposures: SecurityTrailsExposure[] = [];
|
||||||
|
|
||||||
|
// Subdomain exposure: many subdomains = larger attack surface
|
||||||
|
if (domainInfo.subdomains.length > 50) {
|
||||||
|
exposures.push({
|
||||||
|
type: "subdomain_exposure",
|
||||||
|
severity: "warning",
|
||||||
|
detail: `Domain has ${domainInfo.subdomains.length} subdomains — large attack surface`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS misconfiguration: check for common issues
|
||||||
|
for (const record of domainInfo.dnsRecords) {
|
||||||
|
// Open DNS resolver (NS record pointing to non-authoritative)
|
||||||
|
if (record.recordType === "NS" && record.value.includes("google")) {
|
||||||
|
// Google Cloud DNS is fine, skip
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TXT records with SPF but no DMARC
|
||||||
|
if (record.recordType === "TXT" && record.value.includes("v=spf1")) {
|
||||||
|
const hasDmarc = domainInfo.dnsRecords.some(
|
||||||
|
(r) => r.recordType === "TXT" && r.value.includes("v=DMARC1"),
|
||||||
|
);
|
||||||
|
if (!hasDmarc) {
|
||||||
|
exposures.push({
|
||||||
|
type: "dns_misconfiguration",
|
||||||
|
severity: "warning",
|
||||||
|
detail: "SPF record found without DMARC — email spoofing risk",
|
||||||
|
recordType: "TXT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain hijack risk: expiring soon
|
||||||
|
if (domainInfo.whois.expirationDate) {
|
||||||
|
const expDate = new Date(domainInfo.whois.expirationDate);
|
||||||
|
const daysUntilExpiry = (expDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 30 && daysUntilExpiry > 0) {
|
||||||
|
exposures.push({
|
||||||
|
type: "domain_hijack_risk",
|
||||||
|
severity: daysUntilExpiry < 7 ? "critical" : "warning",
|
||||||
|
detail: `Domain expires in ${Math.ceil(daysUntilExpiry)} days — hijacking risk`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exposures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cost tracking
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SecurityTrails Pro: $49/mo for unlimited requests (within reason)
|
||||||
|
// We estimate cost per request for tracking purposes
|
||||||
|
static readonly ESTIMATED_COST_PER_REQUEST = 0.001; // ~$0.001 per request at Pro tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singleton accessor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let client: SecurityTrailsClient | null = null;
|
||||||
|
|
||||||
|
export function getSecurityTrailsClient(): SecurityTrailsClient {
|
||||||
|
if (!client) {
|
||||||
|
const apiKey = process.env.SECURITYTRAILS_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("SECURITYTRAILS_API_KEY environment variable is required");
|
||||||
|
}
|
||||||
|
client = new SecurityTrailsClient(apiKey);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the singleton (useful for testing) */
|
||||||
|
export function resetSecurityTrailsClient(): void {
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
468
web/src/server/services/darkwatch/shodan.client.test.ts
Normal file
468
web/src/server/services/darkwatch/shodan.client.test.ts
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
ShodanClient,
|
||||||
|
resetShodanClient,
|
||||||
|
getShodanClient,
|
||||||
|
} from "./shodan.client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ShodanClient — unit tests with mocked fetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ShodanClient", () => {
|
||||||
|
const apiKey = "test-shodan-key";
|
||||||
|
let client: ShodanClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetShodanClient();
|
||||||
|
client = new ShodanClient(apiKey, 100); // high rate limit for tests
|
||||||
|
vi.spyOn(globalThis, "fetch").mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// search
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
it("returns parsed search results", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
ip_str: "93.184.216.34",
|
||||||
|
ip: 1569383790,
|
||||||
|
ports: [80, 443],
|
||||||
|
hostnames: ["example.com"],
|
||||||
|
org: "Edgecast",
|
||||||
|
country_code: "US",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 80,
|
||||||
|
banner: "Apache/2.4",
|
||||||
|
product: "Apache httpd",
|
||||||
|
http: { title: "Example Domain" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.search("example.com");
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.matches).toHaveLength(1);
|
||||||
|
expect(result.matches[0].ip_str).toBe("93.184.216.34");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes API key in URL", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ matches: [], total: 0 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.search("test.com");
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(`key=${apiKey}`),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty results", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ matches: [], total: 0 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.search("nonexistent.domain.xyz");
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.matches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 429 rate limit", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 429 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow(
|
||||||
|
"Shodan rate limit exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on 401 auth failure", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 401 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow(
|
||||||
|
"Shodan authentication failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// host
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("host", () => {
|
||||||
|
it("returns detailed host info", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ip_str: "93.184.216.34",
|
||||||
|
ip: 1569383790,
|
||||||
|
ports: [80, 443],
|
||||||
|
hostnames: ["example.com"],
|
||||||
|
org: "Edgecast",
|
||||||
|
country_code: "US",
|
||||||
|
country_name: "United States",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 80,
|
||||||
|
banner: "Apache/2.4.41",
|
||||||
|
product: "Apache httpd",
|
||||||
|
version: "2.4.41",
|
||||||
|
http: { title: "Example Domain", server: "Apache" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
port: 443,
|
||||||
|
banner: "Apache/2.4.41",
|
||||||
|
product: "Apache httpd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [],
|
||||||
|
timestamp: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(mockResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.host("93.184.216.34");
|
||||||
|
expect(result?.ip_str).toBe("93.184.216.34");
|
||||||
|
expect(result?.ports).toEqual([80, 443]);
|
||||||
|
expect(result?.data).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on API error", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 404 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.host("1.2.3.4");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// count
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("count", () => {
|
||||||
|
it("returns result count", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ total: 42 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.count("example.com");
|
||||||
|
expect(result.total).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses count endpoint not search", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ total: 0 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.count("test.com");
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/host/count"),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeHostExposures
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("analyzeHostExposures", () => {
|
||||||
|
it("detects Tor exit node", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "185.220.101.1",
|
||||||
|
ip: 3112631041,
|
||||||
|
ports: [9001],
|
||||||
|
tags: ["tor"],
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const torExp = exposures.find((e) => e.type === "tor_exit_node");
|
||||||
|
expect(torExp).toBeDefined();
|
||||||
|
expect(torExp?.severity).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects IoT exposure", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "192.168.1.1",
|
||||||
|
ip: 3232235777,
|
||||||
|
ports: [80],
|
||||||
|
tags: ["iot"],
|
||||||
|
os: "Embedded Linux",
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const iotExp = exposures.find((e) => e.type === "iot_exposure");
|
||||||
|
expect(iotExp).toBeDefined();
|
||||||
|
expect(iotExp?.detail).toContain("IoT device");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects open database on port 3306", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "10.0.0.1",
|
||||||
|
ip: 167772161,
|
||||||
|
ports: [3306],
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 3306,
|
||||||
|
product: "MySQL",
|
||||||
|
version: "8.0.28",
|
||||||
|
banner: "MySQL 8.0.28",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const dbExp = exposures.find((e) => e.type === "open_database");
|
||||||
|
expect(dbExp).toBeDefined();
|
||||||
|
expect(dbExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects admin panel from HTTP title", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [8080],
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 8080,
|
||||||
|
product: "nginx",
|
||||||
|
http: { title: "phpMyAdmin - Login" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const adminExp = exposures.find((e) => e.type === "admin_panel");
|
||||||
|
expect(adminExp).toBeDefined();
|
||||||
|
expect(adminExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects default credentials from banner", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "5.6.7.8",
|
||||||
|
ip: 89043736,
|
||||||
|
ports: [21],
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 21,
|
||||||
|
product: "vsftpd",
|
||||||
|
banner: "220 (vsFTPd 3.0.3) admin/admin login required",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const credExp = exposures.find((e) => e.type === "default_credentials");
|
||||||
|
expect(credExp).toBeDefined();
|
||||||
|
expect(credExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects known vulnerabilities", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "9.10.11.12",
|
||||||
|
ip: 151679868,
|
||||||
|
ports: [443],
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 443,
|
||||||
|
product: "OpenSSL",
|
||||||
|
vulns: ["CVE-2021-44228"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const vulnExp = exposures.find((e) => e.type === "vulnerable_service");
|
||||||
|
expect(vulnExp).toBeDefined();
|
||||||
|
expect(vulnExp?.severity).toBe("critical");
|
||||||
|
expect(vulnExp?.vulns).toContain("CVE-2021-44228");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects exposed Telnet", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [23],
|
||||||
|
data: [{ port: 23, banner: "Telnet" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const telnetExp = exposures.find((e) => e.detail.includes("Telnet"));
|
||||||
|
expect(telnetExp).toBeDefined();
|
||||||
|
expect(telnetExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects exposed RDP", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [3389],
|
||||||
|
data: [{ port: 3389, banner: "RDP" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const rdpExp = exposures.find((e) => e.detail.includes("RDP"));
|
||||||
|
expect(rdpExp).toBeDefined();
|
||||||
|
expect(rdpExp?.severity).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates host-level vulns with port-level vulns", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [443],
|
||||||
|
vulns: ["CVE-2021-44228", "CVE-2024-9999"],
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 443,
|
||||||
|
product: "OpenSSL",
|
||||||
|
vulns: ["CVE-2021-44228"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
const hostVulnExp = exposures.find(
|
||||||
|
(e) => e.type === "vulnerable_service" && !e.port,
|
||||||
|
);
|
||||||
|
// Should only report CVE-2024-9999 at host level since CVE-2021-44228 is already reported at port level
|
||||||
|
expect(hostVulnExp?.vulns).toEqual(["CVE-2024-9999"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no exposures for clean host", () => {
|
||||||
|
const host = {
|
||||||
|
ip_str: "1.2.3.4",
|
||||||
|
ip: 16909060,
|
||||||
|
ports: [80],
|
||||||
|
tags: [],
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
port: 80,
|
||||||
|
product: "nginx",
|
||||||
|
http: { title: "Welcome" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const exposures = client.analyzeHostExposures(host);
|
||||||
|
expect(exposures.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Circuit breaker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("circuit breaker", () => {
|
||||||
|
it("opens after 3 consecutive failures", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow(
|
||||||
|
"Shodan circuit breaker is open",
|
||||||
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets after successful call", { timeout: 10000 }, async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
// Fail once
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow();
|
||||||
|
|
||||||
|
// Succeed
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ matches: [], total: 0 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await client.search("test.com");
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
|
||||||
|
// Circuit should be reset
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(null, { status: 500 }),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow();
|
||||||
|
}
|
||||||
|
await expect(client.search("test.com")).rejects.toThrow(
|
||||||
|
"Shodan circuit breaker is open",
|
||||||
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Singleton
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("singleton", () => {
|
||||||
|
it("creates client from env var", () => {
|
||||||
|
process.env.SHODAN_API_KEY = "env-key";
|
||||||
|
resetShodanClient();
|
||||||
|
const c = getShodanClient();
|
||||||
|
expect(c).toBeInstanceOf(ShodanClient);
|
||||||
|
delete process.env.SHODAN_API_KEY;
|
||||||
|
resetShodanClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when env var missing", () => {
|
||||||
|
delete process.env.SHODAN_API_KEY;
|
||||||
|
resetShodanClient();
|
||||||
|
expect(() => getShodanClient()).toThrow("SHODAN_API_KEY");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
419
web/src/server/services/darkwatch/shodan.client.ts
Normal file
419
web/src/server/services/darkwatch/shodan.client.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { get, set } from "~/server/lib/cache";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ShodanPort {
|
||||||
|
port: number;
|
||||||
|
banner?: string;
|
||||||
|
product?: string;
|
||||||
|
version?: string;
|
||||||
|
vulns?: string[];
|
||||||
|
cpe?: string[];
|
||||||
|
exfiltration_url?: string;
|
||||||
|
http?: {
|
||||||
|
title?: string;
|
||||||
|
meta?: { description?: string; keywords?: string };
|
||||||
|
server?: string;
|
||||||
|
html?: string;
|
||||||
|
};
|
||||||
|
os?: string;
|
||||||
|
device?: string;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShodanHost {
|
||||||
|
ip_str: string;
|
||||||
|
ip: number;
|
||||||
|
ports: number[];
|
||||||
|
headers?: Record<string, string[]>;
|
||||||
|
hostnames?: string[];
|
||||||
|
os?: string;
|
||||||
|
org?: string;
|
||||||
|
country_code?: string;
|
||||||
|
country_name?: string;
|
||||||
|
city?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
asn?: string;
|
||||||
|
isp?: string;
|
||||||
|
domains?: string[];
|
||||||
|
data?: ShodanPort[];
|
||||||
|
tags?: string[];
|
||||||
|
lastUpdate?: string;
|
||||||
|
lastTimedout?: string;
|
||||||
|
lastSeen?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
vulns?: string[];
|
||||||
|
http?: {
|
||||||
|
title?: string;
|
||||||
|
meta?: { description?: string; keywords?: string };
|
||||||
|
server?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShodanSearchResult {
|
||||||
|
matches: ShodanHost[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShodanCountResult {
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShodanExposure {
|
||||||
|
type: "open_port" | "default_credentials" | "iot_exposure" | "tor_exit_node" | "exposed_service" | "admin_panel" | "open_database" | "vulnerable_service";
|
||||||
|
severity: "info" | "warning" | "critical";
|
||||||
|
detail: string;
|
||||||
|
ip?: string;
|
||||||
|
port?: number;
|
||||||
|
service?: string;
|
||||||
|
vulns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawSearchResponse {
|
||||||
|
matches: ShodanHost[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shodan API Client
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CACHE_PREFIX = "shodan";
|
||||||
|
const HOST_CACHE_TTL = 604_800; // 7 days
|
||||||
|
const SEARCH_CACHE_TTL = 86_400; // 24 hours
|
||||||
|
const COUNT_CACHE_TTL = 43_200; // 12 hours
|
||||||
|
|
||||||
|
export class ShodanClient {
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly baseUrl = "https://api.shodan.io/shodan";
|
||||||
|
|
||||||
|
// Circuit breaker state
|
||||||
|
private circuitFailures = 0;
|
||||||
|
private circuitLastFailure = 0;
|
||||||
|
private circuitIsOpen = false;
|
||||||
|
private readonly circuitThreshold = 3;
|
||||||
|
private readonly circuitResetMs = 60_000;
|
||||||
|
|
||||||
|
// Rate limiting (5 req/sec = 200ms interval)
|
||||||
|
private lastRequestTime = 0;
|
||||||
|
private readonly minRequestIntervalMs: number;
|
||||||
|
|
||||||
|
constructor(apiKey: string, requestsPerSecond = 5) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.minRequestIntervalMs = 1000 / Math.max(requestsPerSecond, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Circuit breaker
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private isCircuitOpen(): boolean {
|
||||||
|
if (!this.circuitIsOpen) return false;
|
||||||
|
if (Date.now() - this.circuitLastFailure > this.circuitResetMs) {
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFailure(): void {
|
||||||
|
this.circuitFailures++;
|
||||||
|
this.circuitLastFailure = Date.now();
|
||||||
|
if (this.circuitFailures >= this.circuitThreshold) {
|
||||||
|
this.circuitIsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSuccess(): void {
|
||||||
|
this.circuitFailures = 0;
|
||||||
|
this.circuitLastFailure = 0;
|
||||||
|
this.circuitIsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rate limiter
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async waitForRateLimit(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastRequestTime;
|
||||||
|
if (elapsed < this.minRequestIntervalMs) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, this.minRequestIntervalMs - elapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.lastRequestTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// HTTP helper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async request<T>(url: string): Promise<T | null> {
|
||||||
|
if (this.isCircuitOpen()) {
|
||||||
|
throw new Error("Shodan circuit breaker is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForRateLimit();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("Shodan rate limit exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error("Shodan authentication failed — check API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.recordFailure();
|
||||||
|
throw new Error(`Shodan returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recordSuccess();
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && (err.message.includes("circuit") || err.message.includes("rate limit") || err.message.includes("authentication"))) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.recordFailure();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// search — search exposed devices and services
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async search(query: string, page = 1): Promise<ShodanSearchResult> {
|
||||||
|
const cacheKey = `search:${createHash("sha256").update(`${query}:${page}`).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<ShodanSearchResult>(cacheKey, { prefix: CACHE_PREFIX, ttl: SEARCH_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/host/search?key=${this.apiKey}&query=${encodeURIComponent(query)}&page=${page}`;
|
||||||
|
const data = await this.request<RawSearchResponse>(url);
|
||||||
|
if (!data) return { matches: [], total: 0 };
|
||||||
|
|
||||||
|
const result: ShodanSearchResult = {
|
||||||
|
matches: data.matches ?? [],
|
||||||
|
total: data.total ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(cacheKey, result, { prefix: CACHE_PREFIX, ttl: SEARCH_CACHE_TTL }).catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// host — detailed host information by IP
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async host(ip: string): Promise<ShodanHost | null> {
|
||||||
|
const cacheKey = `host:${createHash("sha256").update(ip.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<ShodanHost>(cacheKey, { prefix: CACHE_PREFIX, ttl: HOST_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/host/${encodeURIComponent(ip)}?key=${this.apiKey}`;
|
||||||
|
try {
|
||||||
|
const data = await this.request<ShodanHost>(url);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
set(cacheKey, data, { prefix: CACHE_PREFIX, ttl: HOST_CACHE_TTL }).catch(() => {});
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// count — result counts for monitoring (cheaper than full search)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async count(query: string): Promise<ShodanCountResult> {
|
||||||
|
const cacheKey = `count:${createHash("sha256").update(query.toLowerCase()).digest("hex").slice(0, 16)}`;
|
||||||
|
const cached = await get<ShodanCountResult>(cacheKey, { prefix: CACHE_PREFIX, ttl: COUNT_CACHE_TTL });
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/host/count?key=${this.apiKey}&query=${encodeURIComponent(query)}`;
|
||||||
|
const data = await this.request<ShodanCountResult>(url);
|
||||||
|
if (!data) return { total: 0 };
|
||||||
|
|
||||||
|
set(cacheKey, data, { prefix: CACHE_PREFIX, ttl: COUNT_CACHE_TTL }).catch(() => {});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// analyzeExposures — heuristic analysis of hosts for security issues
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
analyzeHostExposures(host: ShodanHost): ShodanExposure[] {
|
||||||
|
const exposures: ShodanExposure[] = [];
|
||||||
|
|
||||||
|
// Check for Tor exit node
|
||||||
|
if (host.tags?.includes("tor")) {
|
||||||
|
exposures.push({
|
||||||
|
type: "tor_exit_node",
|
||||||
|
severity: "warning",
|
||||||
|
detail: `IP ${host.ip_str} is a known Tor exit node`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for IoT devices
|
||||||
|
if (host.tags?.includes("iot")) {
|
||||||
|
exposures.push({
|
||||||
|
type: "iot_exposure",
|
||||||
|
severity: "warning",
|
||||||
|
detail: `IoT device exposed: ${host.ip_str}${host.os ? ` (${host.os})` : ""}`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze individual port data
|
||||||
|
const portData = host.data ?? [];
|
||||||
|
for (const port of portData) {
|
||||||
|
// Open databases
|
||||||
|
const dbPorts = new Set([3306, 5432, 6379, 9200, 27017, 1433, 1521]);
|
||||||
|
if (dbPorts.has(port.port)) {
|
||||||
|
exposures.push({
|
||||||
|
type: "open_database",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Database ${port.product ?? "service"} exposed on port ${port.port} (${host.ip_str})`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
port: port.port,
|
||||||
|
service: port.product,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin panels
|
||||||
|
const adminPatterns = ["admin", "dashboard", "cpanel", "phpmyadmin", "webmin", "router"];
|
||||||
|
if (port.http?.title) {
|
||||||
|
const titleLower = port.http.title.toLowerCase();
|
||||||
|
for (const pattern of adminPatterns) {
|
||||||
|
if (titleLower.includes(pattern)) {
|
||||||
|
exposures.push({
|
||||||
|
type: "admin_panel",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Admin panel exposed: "${port.http.title}" on port ${port.port} (${host.ip_str})`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
port: port.port,
|
||||||
|
service: port.product,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default credential indicators (common banners)
|
||||||
|
const defaultCredPatterns = ["admin/admin", "root/root", "default", "login required"];
|
||||||
|
if (port.banner) {
|
||||||
|
const bannerLower = port.banner.toLowerCase();
|
||||||
|
for (const pattern of defaultCredPatterns) {
|
||||||
|
if (bannerLower.includes(pattern)) {
|
||||||
|
exposures.push({
|
||||||
|
type: "default_credentials",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Possible default credentials on port ${port.port} (${host.ip_str}): ${bannerLower.slice(0, 100)}`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
port: port.port,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known vulnerabilities
|
||||||
|
if (port.vulns?.length) {
|
||||||
|
exposures.push({
|
||||||
|
type: "vulnerable_service",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Service on port ${port.port} has known vulnerabilities: ${port.vulns.join(", ")}`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
port: port.port,
|
||||||
|
service: port.product,
|
||||||
|
vulns: port.vulns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical ports
|
||||||
|
const criticalPorts = new Map<number, string>([
|
||||||
|
[23, "Telnet"],
|
||||||
|
[3389, "RDP"],
|
||||||
|
[5900, "VNC"],
|
||||||
|
[21, "FTP"],
|
||||||
|
]);
|
||||||
|
const criticalLabel = criticalPorts.get(port.port);
|
||||||
|
if (criticalLabel) {
|
||||||
|
exposures.push({
|
||||||
|
type: "exposed_service",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `${criticalLabel} exposed on port ${port.port} (${host.ip_str})`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
port: port.port,
|
||||||
|
service: criticalLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host-level vulnerabilities
|
||||||
|
if (host.vulns?.length) {
|
||||||
|
// Deduplicate with port-level vulns already reported
|
||||||
|
const reportedVulns = new Set(exposures.flatMap((e) => e.vulns ?? []));
|
||||||
|
const newVulns = host.vulns.filter((v) => !reportedVulns.has(v));
|
||||||
|
if (newVulns.length) {
|
||||||
|
exposures.push({
|
||||||
|
type: "vulnerable_service",
|
||||||
|
severity: "critical",
|
||||||
|
detail: `Host ${host.ip_str} has vulnerabilities: ${newVulns.join(", ")}`,
|
||||||
|
ip: host.ip_str,
|
||||||
|
vulns: newVulns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exposures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cost tracking
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Shodan Small Business: $299/mo
|
||||||
|
static readonly ESTIMATED_COST_PER_REQUEST = 0.005; // ~$0.005 per request at Small Biz tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singleton accessor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let client: ShodanClient | null = null;
|
||||||
|
|
||||||
|
export function getShodanClient(): ShodanClient {
|
||||||
|
if (!client) {
|
||||||
|
const apiKey = process.env.SHODAN_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("SHODAN_API_KEY environment variable is required");
|
||||||
|
}
|
||||||
|
client = new ShodanClient(apiKey);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the singleton (useful for testing) */
|
||||||
|
export function resetShodanClient(): void {
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
alertNotificationEmail,
|
alertNotificationEmail,
|
||||||
passwordResetEmail,
|
passwordResetEmail,
|
||||||
familyInviteEmail,
|
familyInviteEmail,
|
||||||
|
familyInviteReminderEmail,
|
||||||
|
familyMemberAddedEmail,
|
||||||
|
familyMemberRemovedEmail,
|
||||||
billingReceiptEmail,
|
billingReceiptEmail,
|
||||||
|
paymentFailedEmail,
|
||||||
|
subscriptionActivatedEmail,
|
||||||
} from "./email.templates";
|
} from "./email.templates";
|
||||||
|
|
||||||
describe("welcomeEmail", () => {
|
describe("welcomeEmail", () => {
|
||||||
@@ -64,3 +69,62 @@ describe("billingReceiptEmail", () => {
|
|||||||
expect(result.subject).toContain("Premium Plan");
|
expect(result.subject).toContain("Premium Plan");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("paymentFailedEmail", () => {
|
||||||
|
it("includes user name and portal URL", () => {
|
||||||
|
const result = paymentFailedEmail("Alice", "https://billing.stripe.com/portal/abc");
|
||||||
|
expect(result.subject).toContain("Payment failed");
|
||||||
|
expect(result.html).toContain("Alice");
|
||||||
|
expect(result.html).toContain("https://billing.stripe.com/portal/abc");
|
||||||
|
expect(result.html).toContain("grace period");
|
||||||
|
expect(result.text).toContain("Alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("familyInviteReminderEmail", () => {
|
||||||
|
it("includes inviter name, group name, and accept link", () => {
|
||||||
|
const result = familyInviteReminderEmail("Bob", "Smith Family", "https://kordant.ai/invite/abc");
|
||||||
|
expect(result.html).toContain("Bob");
|
||||||
|
expect(result.html).toContain("Smith Family");
|
||||||
|
expect(result.html).toContain("https://kordant.ai/invite/abc");
|
||||||
|
expect(result.subject).toContain("Reminder");
|
||||||
|
expect(result.subject).toContain("Bob");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("familyMemberAddedEmail", () => {
|
||||||
|
it("includes primary name, member name, and group name", () => {
|
||||||
|
const result = familyMemberAddedEmail("Alice", "Bob", "Smith Family");
|
||||||
|
expect(result.html).toContain("Bob");
|
||||||
|
expect(result.html).toContain("Smith Family");
|
||||||
|
expect(result.subject).toContain("Bob");
|
||||||
|
expect(result.subject).toContain("joined");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("familyMemberRemovedEmail", () => {
|
||||||
|
it("includes primary name and group name", () => {
|
||||||
|
const result = familyMemberRemovedEmail("Alice", "Smith Family");
|
||||||
|
expect(result.html).toContain("Smith Family");
|
||||||
|
expect(result.html).toContain("membership");
|
||||||
|
expect(result.subject).toContain("removed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscriptionActivatedEmail", () => {
|
||||||
|
it("includes trial info when trialDays > 0", () => {
|
||||||
|
const result = subscriptionActivatedEmail("Bob", "Basic", 14);
|
||||||
|
expect(result.subject).toContain("Basic");
|
||||||
|
expect(result.html).toContain("Bob");
|
||||||
|
expect(result.html).toContain("14-day free trial");
|
||||||
|
expect(result.html).toContain("Basic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows paid activation when trialDays is 0", () => {
|
||||||
|
const result = subscriptionActivatedEmail("Carol", "Premium", 0);
|
||||||
|
expect(result.subject).toContain("Premium");
|
||||||
|
expect(result.html).toContain("Carol");
|
||||||
|
expect(result.html).toContain("Premium subscription is now active");
|
||||||
|
expect(result.html).not.toContain("trial");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user