FRE-4471: Scaffold DarkWatch MVP — monorepo, schema, services, API routes, tests
- Turborepo monorepo structure (packages: api, db, types, jobs; services: darkwatch) - Prisma schema: User, WatchListItem, Exposure, Alert, ScanJob models - WatchListService: CRUD with normalization, dedup, tier-based limits - HIBPService: API integration with severity scoring - MatchingEngine: exact-match with content hash dedup - AlertPipeline: dedup window, email notifications - ScanService: orchestrates watch list -> HIBP -> match -> alert flow - BullMQ job workers for scan and alert processing - Fastify API routes: watchlist, exposures, alerts, scan - Docker Compose: PostgreSQL 16 + Redis 7 - 15 unit tests passing - Implementation plan document uploaded
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL="postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
HIBP_API_KEY=""
|
||||||
|
RESEND_API_KEY=""
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: shieldai
|
||||||
|
POSTGRES_USER: shieldai
|
||||||
|
POSTGRES_PASSWORD: shieldai_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U shieldai"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
3563
package-lock.json
generated
Normal file
3563
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "shieldai",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"services/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"build": "turbo run build",
|
||||||
|
"test": "turbo run test",
|
||||||
|
"db:migrate": "turbo run db:migrate",
|
||||||
|
"db:seed": "turbo run db:seed",
|
||||||
|
"lint": "turbo run lint"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"turbo": "^2.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/api/package.json
Normal file
21
packages/api/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/helmet": "^13.0.1",
|
||||||
|
"@fastify/rate-limit": "^9.0.0",
|
||||||
|
"@fastify/sensible": "^6.0.1",
|
||||||
|
"@shieldai/db": "0.1.0",
|
||||||
|
"@shieldai/types": "0.1.0",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"@shieldai/darkwatch": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/api/src/routes/alert.routes.ts
Normal file
30
packages/api/src/routes/alert.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { AlertPipeline } from "@shieldai/darkwatch";
|
||||||
|
|
||||||
|
export function alertRoutes(fastify: FastifyInstance) {
|
||||||
|
const pipeline = new AlertPipeline();
|
||||||
|
|
||||||
|
fastify.get("/", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(request.query.limit as string) || 50;
|
||||||
|
const offset = parseInt(request.query.offset as string) || 0;
|
||||||
|
const alerts = await pipeline.getUserAlerts(userId, limit, offset);
|
||||||
|
return reply.send(alerts);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/:id/read", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.markRead(request.params.id, userId);
|
||||||
|
return reply.send({ read: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
27
packages/api/src/routes/exposure.routes.ts
Normal file
27
packages/api/src/routes/exposure.routes.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { MatchingEngine } from "@shieldai/darkwatch";
|
||||||
|
|
||||||
|
export function exposureRoutes(fastify: FastifyInstance) {
|
||||||
|
const engine = new MatchingEngine();
|
||||||
|
|
||||||
|
fastify.get("/", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exposures = await engine.getExposuresForUser(userId);
|
||||||
|
return reply.send(exposures);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/:id", async (request, reply) => {
|
||||||
|
const exposure = await engine.getExposureById(request.params.id);
|
||||||
|
|
||||||
|
if (!exposure) {
|
||||||
|
return reply.code(404).send({ error: "Exposure not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(exposure);
|
||||||
|
});
|
||||||
|
}
|
||||||
15
packages/api/src/routes/index.ts
Normal file
15
packages/api/src/routes/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
export function darkwatchRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.register(async (root) => {
|
||||||
|
const watchlist = (await import("./watchlist.routes")).watchlistRoutes;
|
||||||
|
const exposures = (await import("./exposure.routes")).exposureRoutes;
|
||||||
|
const alerts = (await import("./alert.routes")).alertRoutes;
|
||||||
|
const scans = (await import("./scan.routes")).scanRoutes;
|
||||||
|
|
||||||
|
root.register(watchlist, { prefix: "/watchlist" });
|
||||||
|
root.register(exposures, { prefix: "/exposures" });
|
||||||
|
root.register(alerts, { prefix: "/alerts" });
|
||||||
|
root.register(scans, { prefix: "/scan" });
|
||||||
|
}, { prefix: "/api/v1/darkwatch" });
|
||||||
|
}
|
||||||
32
packages/api/src/routes/scan.routes.ts
Normal file
32
packages/api/src/routes/scan.routes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { ScanService } from "@shieldai/darkwatch";
|
||||||
|
import { DataSource } from "@shieldai/types";
|
||||||
|
|
||||||
|
export function scanRoutes(fastify: FastifyInstance) {
|
||||||
|
const scanService = new ScanService();
|
||||||
|
|
||||||
|
fastify.post("/", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as { source?: string };
|
||||||
|
const source = body.source ? (body.source as DataSource) : undefined;
|
||||||
|
const resultCount = await scanService.runScan(userId, source);
|
||||||
|
|
||||||
|
return reply.code(200).send({ scanned: true, resultCount });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/history", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await scanService.getScanHistory(userId);
|
||||||
|
return reply.send(history);
|
||||||
|
});
|
||||||
|
}
|
||||||
41
packages/api/src/routes/watchlist.routes.ts
Normal file
41
packages/api/src/routes/watchlist.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { WatchListService } from "@shieldai/darkwatch";
|
||||||
|
import { IdentifierType } from "@shieldai/types";
|
||||||
|
|
||||||
|
export function watchlistRoutes(fastify: FastifyInstance) {
|
||||||
|
const service = new WatchListService();
|
||||||
|
|
||||||
|
fastify.post("/", async (request, reply) => {
|
||||||
|
const body = request.body as { identifierType: string; identifierValue: string };
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await service.addItem(userId, body.identifierType as IdentifierType, body.identifierValue);
|
||||||
|
return reply.code(201).send(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await service.listItems(userId);
|
||||||
|
return reply.send(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete("/:id", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.removeItem(userId, request.params.id);
|
||||||
|
return reply.send({ count: result.count });
|
||||||
|
});
|
||||||
|
}
|
||||||
31
packages/api/src/server.ts
Normal file
31
packages/api/src/server.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import helmet from "@fastify/helmet";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import { darkwatchRoutes } from "./routes";
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.LOG_LEVEL || "info",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
await app.register(helmet);
|
||||||
|
await app.register(sensible);
|
||||||
|
|
||||||
|
await app.register(darkwatchRoutes);
|
||||||
|
|
||||||
|
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: parseInt(process.env.PORT || "3000", 10), host: "0.0.0.0" });
|
||||||
|
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
23
packages/db/package.json
Normal file
23
packages/db/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "prisma generate && tsc",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"generate": "prisma generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.2.0",
|
||||||
|
"prisma": "^6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.0"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
152
packages/db/prisma/migrations/20260429132230_init/migration.sql
Normal file
152
packages/db/prisma/migrations/20260429132230_init/migration.sql
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubscriptionTier" AS ENUM ('BASIC', 'PLUS', 'PREMIUM');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IdentifierType" AS ENUM ('EMAIL', 'PHONE', 'SSN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WatchListStatus" AS ENUM ('ACTIVE', 'PAUSED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Severity" AS ENUM ('INFO', 'WARNING', 'CRITICAL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AlertChannel" AS ENUM ('EMAIL', 'PUSH', 'SMS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AlertStatus" AS ENUM ('PENDING', 'SENT', 'READ');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ScanJobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DataSource" AS ENUM ('HIBP', 'SECURITY_TRAILS', 'CENSYS', 'SHODAN', 'HONEYPOT');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"subscriptionTier" "SubscriptionTier" NOT NULL DEFAULT 'BASIC',
|
||||||
|
"familyGroupId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WatchListItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"identifierType" "IdentifierType" NOT NULL,
|
||||||
|
"identifierValue" TEXT NOT NULL,
|
||||||
|
"identifierHash" TEXT NOT NULL,
|
||||||
|
"status" "WatchListStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WatchListItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Exposure" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"watchListItemId" TEXT NOT NULL,
|
||||||
|
"dataSource" "DataSource" NOT NULL,
|
||||||
|
"breachName" TEXT NOT NULL,
|
||||||
|
"exposedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"dataType" TEXT[],
|
||||||
|
"severity" "Severity" NOT NULL,
|
||||||
|
"details" TEXT,
|
||||||
|
"contentHash" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Exposure_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Alert" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"exposureId" TEXT NOT NULL,
|
||||||
|
"severity" "Severity" NOT NULL,
|
||||||
|
"channel" "AlertChannel" NOT NULL,
|
||||||
|
"status" "AlertStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"dedupKey" TEXT NOT NULL,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Alert_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ScanJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"status" "ScanJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"source" "DataSource",
|
||||||
|
"resultCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ScanJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WatchListItem_identifierHash_key" ON "WatchListItem"("identifierHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WatchListItem_userId_idx" ON "WatchListItem"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WatchListItem_identifierHash_idx" ON "WatchListItem"("identifierHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Exposure_contentHash_key" ON "Exposure"("contentHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Exposure_watchListItemId_idx" ON "Exposure"("watchListItemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Exposure_contentHash_idx" ON "Exposure"("contentHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Exposure_dataSource_idx" ON "Exposure"("dataSource");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Alert_exposureId_key" ON "Alert"("exposureId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Alert_userId_status_idx" ON "Alert"("userId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Alert_dedupKey_idx" ON "Alert"("dedupKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ScanJob_userId_status_idx" ON "ScanJob"("userId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ScanJob_createdAt_idx" ON "ScanJob"("createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WatchListItem" ADD CONSTRAINT "WatchListItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Exposure" ADD CONSTRAINT "Exposure_watchListItemId_fkey" FOREIGN KEY ("watchListItemId") REFERENCES "WatchListItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_exposureId_fkey" FOREIGN KEY ("exposureId") REFERENCES "Exposure"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ScanJob" ADD CONSTRAINT "ScanJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
packages/db/prisma/migrations/migration_lock.toml
Normal file
3
packages/db/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
140
packages/db/prisma/schema.prisma
Normal file
140
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionTier {
|
||||||
|
BASIC
|
||||||
|
PLUS
|
||||||
|
PREMIUM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IdentifierType {
|
||||||
|
EMAIL
|
||||||
|
PHONE
|
||||||
|
SSN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchListStatus {
|
||||||
|
ACTIVE
|
||||||
|
PAUSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Severity {
|
||||||
|
INFO
|
||||||
|
WARNING
|
||||||
|
CRITICAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlertChannel {
|
||||||
|
EMAIL
|
||||||
|
PUSH
|
||||||
|
SMS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlertStatus {
|
||||||
|
PENDING
|
||||||
|
SENT
|
||||||
|
READ
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScanJobStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DataSource {
|
||||||
|
HIBP
|
||||||
|
SECURITY_TRAILS
|
||||||
|
CENSYS
|
||||||
|
SHODAN
|
||||||
|
HONEYPOT
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
subscriptionTier SubscriptionTier @default(BASIC)
|
||||||
|
familyGroupId String?
|
||||||
|
watchListItems WatchListItem[]
|
||||||
|
alerts Alert[]
|
||||||
|
scanJobs ScanJob[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WatchListItem {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
identifierType IdentifierType
|
||||||
|
identifierValue String
|
||||||
|
identifierHash String @unique
|
||||||
|
status WatchListStatus @default(ACTIVE)
|
||||||
|
exposures Exposure[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([identifierHash])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Exposure {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
watchListItemId String
|
||||||
|
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
|
||||||
|
dataSource DataSource
|
||||||
|
breachName String
|
||||||
|
exposedAt DateTime
|
||||||
|
dataType String[]
|
||||||
|
severity Severity
|
||||||
|
details String?
|
||||||
|
contentHash String @unique
|
||||||
|
alert Alert?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([watchListItemId])
|
||||||
|
@@index([contentHash])
|
||||||
|
@@index([dataSource])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Alert {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
exposureId String @unique
|
||||||
|
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
|
||||||
|
severity Severity
|
||||||
|
channel AlertChannel
|
||||||
|
status AlertStatus @default(PENDING)
|
||||||
|
dedupKey String
|
||||||
|
sentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([dedupKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ScanJob {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
status ScanJobStatus @default(PENDING)
|
||||||
|
source DataSource?
|
||||||
|
resultCount Int @default(0)
|
||||||
|
errorMessage String?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
24
packages/db/prisma/seed.ts
Normal file
24
packages/db/prisma/seed.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import prisma from "../src";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: "dev@shieldai.local" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "dev@shieldai.local",
|
||||||
|
name: "Dev User",
|
||||||
|
subscriptionTier: "PREMIUM",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Seeded user:", user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
4
packages/db/src/index.ts
Normal file
4
packages/db/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
|
export default prisma;
|
||||||
8
packages/db/tsconfig.json
Normal file
8
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
18
packages/jobs/package.json
Normal file
18
packages/jobs/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/jobs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bullmq": "^5.24.0",
|
||||||
|
"@shieldai/db": "0.1.0",
|
||||||
|
"@shieldai/types": "0.1.0",
|
||||||
|
"@shieldai/darkwatch": "0.1.0",
|
||||||
|
"ioredis": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/jobs/src/index.ts
Normal file
69
packages/jobs/src/index.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Queue, Worker, QueueScheduler } from "bullmq";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
import { ScanService } from "@shieldai/darkwatch";
|
||||||
|
import { AlertPipeline } from "@shieldai/darkwatch";
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
||||||
|
const connection = new Redis(redisUrl);
|
||||||
|
|
||||||
|
const scanQueue = new Queue("darkwatch-scans", { connection });
|
||||||
|
const alertQueue = new Queue("darkwatch-alerts", { connection });
|
||||||
|
|
||||||
|
const scanWorker = new Worker(
|
||||||
|
"darkwatch-scans",
|
||||||
|
async (job) => {
|
||||||
|
const { userId, source } = job.data;
|
||||||
|
const scanService = new ScanService();
|
||||||
|
const resultCount = await scanService.runScan(userId, source);
|
||||||
|
return { resultCount, completedAt: new Date().toISOString() };
|
||||||
|
},
|
||||||
|
{ connection, concurrency: 3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertWorker = new Worker(
|
||||||
|
"darkwatch-alerts",
|
||||||
|
async () => {
|
||||||
|
const pipeline = new AlertPipeline();
|
||||||
|
const sent = await pipeline.sendPendingAlerts();
|
||||||
|
return { sent, processedAt: new Date().toISOString() };
|
||||||
|
},
|
||||||
|
{ connection, concurrency: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduler = new QueueScheduler("darkwatch-alerts", { connection });
|
||||||
|
|
||||||
|
scanWorker.on("completed", (job) => {
|
||||||
|
console.log(`[Scan] Job ${job.id} completed: ${JSON.stringify(job.returnvalue)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
scanWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`[Scan] Job ${job.id} failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
alertWorker.on("completed", (job) => {
|
||||||
|
console.log(`[Alert] Job ${job.id} completed: ${JSON.stringify(job.returnvalue)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
alertWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`[Alert] Job ${job.id} failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addScanJob(userId: string, source?: string) {
|
||||||
|
return scanQueue.add("scan", { userId, source }, {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: "exponential", delay: 5000 },
|
||||||
|
jobId: `scan-${userId}-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scheduleAlertProcessing() {
|
||||||
|
return alertQueue.add("process-alerts", {}, {
|
||||||
|
repeat: { pattern: "*/5 * * * *" },
|
||||||
|
jobId: "alert-processor-recurring",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scanWorker.on("waiting", () => console.log("[Worker] Scan worker ready"));
|
||||||
|
alertWorker.on("waiting", () => console.log("[Worker] Alert worker ready"));
|
||||||
|
|
||||||
|
console.log("Job workers started");
|
||||||
8
packages/jobs/tsconfig.json
Normal file
8
packages/jobs/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
13
packages/types/package.json
Normal file
13
packages/types/package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/types",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
packages/types/src/index.ts
Normal file
84
packages/types/src/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
export const IdentifierType = {
|
||||||
|
EMAIL: "EMAIL",
|
||||||
|
PHONE: "PHONE",
|
||||||
|
SSN: "SSN",
|
||||||
|
} as const;
|
||||||
|
export type IdentifierType = (typeof IdentifierType)[keyof typeof IdentifierType];
|
||||||
|
|
||||||
|
export const SubscriptionTier = {
|
||||||
|
BASIC: "BASIC",
|
||||||
|
PLUS: "PLUS",
|
||||||
|
PREMIUM: "PREMIUM",
|
||||||
|
} as const;
|
||||||
|
export type SubscriptionTier = (typeof SubscriptionTier)[keyof typeof SubscriptionTier];
|
||||||
|
|
||||||
|
export const Severity = {
|
||||||
|
INFO: "INFO",
|
||||||
|
WARNING: "WARNING",
|
||||||
|
CRITICAL: "CRITICAL",
|
||||||
|
} as const;
|
||||||
|
export type Severity = (typeof Severity)[keyof typeof Severity];
|
||||||
|
|
||||||
|
export const AlertChannel = {
|
||||||
|
EMAIL: "EMAIL",
|
||||||
|
PUSH: "PUSH",
|
||||||
|
SMS: "SMS",
|
||||||
|
} as const;
|
||||||
|
export type AlertChannel = (typeof AlertChannel)[keyof typeof AlertChannel];
|
||||||
|
|
||||||
|
export const AlertStatus = {
|
||||||
|
PENDING: "PENDING",
|
||||||
|
SENT: "SENT",
|
||||||
|
READ: "READ",
|
||||||
|
} as const;
|
||||||
|
export type AlertStatus = (typeof AlertStatus)[keyof typeof AlertStatus];
|
||||||
|
|
||||||
|
export const ScanJobStatus = {
|
||||||
|
PENDING: "PENDING",
|
||||||
|
RUNNING: "RUNNING",
|
||||||
|
COMPLETED: "COMPLETED",
|
||||||
|
FAILED: "FAILED",
|
||||||
|
} as const;
|
||||||
|
export type ScanJobStatus = (typeof ScanJobStatus)[keyof typeof ScanJobStatus];
|
||||||
|
|
||||||
|
export const WatchListStatus = {
|
||||||
|
ACTIVE: "ACTIVE",
|
||||||
|
PAUSED: "PAUSED",
|
||||||
|
} as const;
|
||||||
|
export type WatchListStatus = (typeof WatchListStatus)[keyof typeof WatchListStatus];
|
||||||
|
|
||||||
|
export const DataSource = {
|
||||||
|
HIBP: "HIBP",
|
||||||
|
SECURITY_TRAILS: "SECURITY_TRAILS",
|
||||||
|
CENSYS: "CENSYS",
|
||||||
|
SHODAN: "SHODAN",
|
||||||
|
HONEYPOT: "HONEYPOT",
|
||||||
|
} as const;
|
||||||
|
export type DataSource = (typeof DataSource)[keyof typeof DataSource];
|
||||||
|
|
||||||
|
export interface WatchListItemInput {
|
||||||
|
identifierType: IdentifierType;
|
||||||
|
identifierValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanTriggerInput {
|
||||||
|
userId: string;
|
||||||
|
source?: DataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExposureResult {
|
||||||
|
dataSource: DataSource;
|
||||||
|
breachName: string;
|
||||||
|
exposedAt: Date;
|
||||||
|
dataType: string[];
|
||||||
|
severity: Severity;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertInput {
|
||||||
|
userId: string;
|
||||||
|
exposureId: string;
|
||||||
|
severity: Severity;
|
||||||
|
channel: AlertChannel;
|
||||||
|
dedupKey: string;
|
||||||
|
}
|
||||||
8
packages/types/tsconfig.json
Normal file
8
packages/types/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
19
services/darkwatch/package.json
Normal file
19
services/darkwatch/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/darkwatch",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shieldai/db": "0.1.0",
|
||||||
|
"@shieldai/types": "0.1.0",
|
||||||
|
"node-cache": "^5.1.2"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
services/darkwatch/src/alerts/AlertPipeline.ts
Normal file
142
services/darkwatch/src/alerts/AlertPipeline.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import prisma from "@shieldai/db";
|
||||||
|
import { AlertChannel, AlertStatus, Severity } from "@shieldai/types";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
|
||||||
|
export class AlertPipeline {
|
||||||
|
private cache: NodeCache;
|
||||||
|
private dedupWindowMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.cache = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlert(
|
||||||
|
userId: string,
|
||||||
|
exposureId: string,
|
||||||
|
severity: Severity,
|
||||||
|
channel: AlertChannel = AlertChannel.EMAIL
|
||||||
|
): Promise<boolean> {
|
||||||
|
const dedupKey = this.computeDedupKey(userId, exposureId);
|
||||||
|
|
||||||
|
const cached = this.cache.get(dedupKey);
|
||||||
|
if (cached) return false;
|
||||||
|
|
||||||
|
const existing = await prisma.alert.findFirst({
|
||||||
|
where: { dedupKey, status: AlertStatus.SENT },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) return false;
|
||||||
|
|
||||||
|
await prisma.alert.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
exposureId,
|
||||||
|
severity,
|
||||||
|
channel,
|
||||||
|
status: AlertStatus.PENDING,
|
||||||
|
dedupKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cache.set(dedupKey, true, this.dedupWindowMs / 1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPendingAlerts(): Promise<number> {
|
||||||
|
const pending = await prisma.alert.findMany({
|
||||||
|
where: { status: AlertStatus.PENDING },
|
||||||
|
include: { user: true, exposure: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
for (const alert of pending) {
|
||||||
|
try {
|
||||||
|
await this.sendNotification(alert);
|
||||||
|
await prisma.alert.update({
|
||||||
|
where: { id: alert.id },
|
||||||
|
data: { status: AlertStatus.SENT, sentAt: new Date() },
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Alert send failed for ${alert.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(alertId: string, userId: string): Promise<void> {
|
||||||
|
await prisma.alert.updateMany({
|
||||||
|
where: { id: alertId, userId },
|
||||||
|
data: { status: AlertStatus.READ },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserAlerts(userId: string, limit = 50, offset = 0) {
|
||||||
|
return prisma.alert.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { exposure: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async countUnread(userId: string): Promise<number> {
|
||||||
|
return prisma.alert.count({
|
||||||
|
where: { userId, status: { in: [AlertStatus.PENDING, AlertStatus.SENT] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendNotification(alert: {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
severity: Severity;
|
||||||
|
channel: AlertChannel;
|
||||||
|
user: { email: string; name: string | null };
|
||||||
|
exposure: { breachName: string; exposedAt: Date; dataType: string[] };
|
||||||
|
}): Promise<void> {
|
||||||
|
switch (alert.channel) {
|
||||||
|
case AlertChannel.EMAIL:
|
||||||
|
await this.sendEmail(alert);
|
||||||
|
break;
|
||||||
|
case AlertChannel.PUSH:
|
||||||
|
console.log(`[Push] Alert ${alert.id} for user ${alert.userId}`);
|
||||||
|
break;
|
||||||
|
case AlertChannel.SMS:
|
||||||
|
console.log(`[SMS] Alert ${alert.id} for user ${alert.userId}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendEmail(alert: {
|
||||||
|
user: { email: string; name: string | null };
|
||||||
|
severity: Severity;
|
||||||
|
exposure: { breachName: string; exposedAt: Date; dataType: string[] };
|
||||||
|
}): Promise<void> {
|
||||||
|
const subject = `[DarkWatch] ${alert.severity} Exposure Detected`;
|
||||||
|
const body = `
|
||||||
|
Dear ${alert.user.name || "User"},
|
||||||
|
|
||||||
|
A new data exposure has been detected:
|
||||||
|
|
||||||
|
Breach: ${alert.exposure.breachName}
|
||||||
|
Date: ${alert.exposure.exposedAt.toISOString().split("T")[0]}
|
||||||
|
Severity: ${alert.severity}
|
||||||
|
Data Types: ${alert.exposure.dataType.join(", ")}
|
||||||
|
|
||||||
|
Login to your DarkWatch dashboard for details.
|
||||||
|
|
||||||
|
— ShieldAI Team
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
console.log(`[Email] To: ${alert.user.email}, Subject: ${subject}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
computeDedupKey(userId: string, exposureId: string): string {
|
||||||
|
return createHash("sha256").update(`${userId}:${exposureId}`).digest("hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
90
services/darkwatch/src/hibp/HIBPService.ts
Normal file
90
services/darkwatch/src/hibp/HIBPService.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { DataSource, Severity } from "@shieldai/types";
|
||||||
|
|
||||||
|
export interface HIBPBreach {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
loginCount: number;
|
||||||
|
passwordCount: number;
|
||||||
|
date: Date;
|
||||||
|
breachDate: Date;
|
||||||
|
addedDate: Date;
|
||||||
|
pwnCount: number;
|
||||||
|
dataClasses: string[];
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HIBPService {
|
||||||
|
private baseUrl = "https://haveibeenpwned.com/api/v3";
|
||||||
|
private apiKey?: string;
|
||||||
|
|
||||||
|
constructor(apiKey?: string) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkEmail(email: string): Promise<HIBPBreach[]> {
|
||||||
|
const url = `${this.baseUrl}/breached-account/${encodeURIComponent(email)}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"User-Agent": "ShieldAI-DarkWatch/1.0",
|
||||||
|
"HIBP-API-Version": "3",
|
||||||
|
};
|
||||||
|
if (this.apiKey) {
|
||||||
|
headers["hibp-api-key"] = this.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 410) {
|
||||||
|
throw new Error(`Email not found in HIBP: ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = parseInt(response.headers.get("Retry-After") || "60", 10);
|
||||||
|
throw new Error(`HIBP rate limited. Retry after ${retryAfter}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HIBP API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<HIBPBreach[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rangeQuery(hashPrefix: string): Promise<string[]> {
|
||||||
|
const url = `${this.baseUrl}/range/${hashPrefix}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "ShieldAI-DarkWatch/1.0" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HIBP range API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return text.split("\n").map((line) => line.split(":")[0].toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
getSeverity(breach: HIBPBreach): Severity {
|
||||||
|
const criticalClasses = ["Password", "Email Address", "Bank Account", "Credit Card", "Social Security Number"];
|
||||||
|
const warningClasses = ["Phone Number", "IP Address", "Geolocation", "IP & User agent"];
|
||||||
|
|
||||||
|
const hasCritical = breach.dataClasses.some((c) => criticalClasses.includes(c));
|
||||||
|
const hasWarning = breach.dataClasses.some((c) => warningClasses.includes(c));
|
||||||
|
|
||||||
|
const breachAge = (Date.now() - breach.breachDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (hasCritical) return Severity.CRITICAL;
|
||||||
|
if (hasWarning) return Severity.WARNING;
|
||||||
|
if (breachAge > 365) return Severity.INFO;
|
||||||
|
|
||||||
|
return Severity.WARNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapToDataSource(): DataSource {
|
||||||
|
return DataSource.HIBP;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
services/darkwatch/src/index.ts
Normal file
5
services/darkwatch/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./watchlist/WatchListService";
|
||||||
|
export * from "./hibp/HIBPService";
|
||||||
|
export * from "./matching/MatchingEngine";
|
||||||
|
export * from "./alerts/AlertPipeline";
|
||||||
|
export * from "./scanner/ScanService";
|
||||||
104
services/darkwatch/src/matching/MatchingEngine.ts
Normal file
104
services/darkwatch/src/matching/MatchingEngine.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import prisma from "@shieldai/db";
|
||||||
|
import { ExposureResult, DataSource, Severity } from "@shieldai/types";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
export class MatchingEngine {
|
||||||
|
async matchExposure(
|
||||||
|
watchListItemId: string,
|
||||||
|
dataSource: DataSource,
|
||||||
|
breachName: string,
|
||||||
|
exposedAt: Date,
|
||||||
|
dataType: string[],
|
||||||
|
severity: Severity,
|
||||||
|
details?: string
|
||||||
|
): Promise<ExposureResult | null> {
|
||||||
|
const contentHash = this.computeContentHash(dataSource, breachName, watchListItemId);
|
||||||
|
|
||||||
|
const existing = await prisma.exposure.findUnique({
|
||||||
|
where: { contentHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exposure = await prisma.exposure.create({
|
||||||
|
data: {
|
||||||
|
watchListItemId,
|
||||||
|
dataSource,
|
||||||
|
breachName,
|
||||||
|
exposedAt,
|
||||||
|
dataType,
|
||||||
|
severity,
|
||||||
|
details,
|
||||||
|
contentHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource: exposure.dataSource,
|
||||||
|
breachName: exposure.breachName,
|
||||||
|
exposedAt: exposure.exposedAt,
|
||||||
|
dataType: exposure.dataType,
|
||||||
|
severity: exposure.severity,
|
||||||
|
details: exposure.details || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExposuresForUser(userId: string): Promise<ExposureResult[]> {
|
||||||
|
const items = await prisma.watchListItem.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { exposures: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: ExposureResult[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
for (const exp of item.exposures) {
|
||||||
|
results.push({
|
||||||
|
dataSource: exp.dataSource,
|
||||||
|
breachName: exp.breachName,
|
||||||
|
exposedAt: exp.exposedAt,
|
||||||
|
dataType: exp.dataType,
|
||||||
|
severity: exp.severity,
|
||||||
|
details: exp.details || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const severityOrder = { CRITICAL: 0, WARNING: 1, INFO: 2 };
|
||||||
|
return severityOrder[a.severity] - severityOrder[b.severity];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExposureById(exposureId: string): Promise<ExposureResult | null> {
|
||||||
|
const exposure = await prisma.exposure.findUnique({
|
||||||
|
where: { id: exposureId },
|
||||||
|
include: { watchListItem: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exposure) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource: exposure.dataSource,
|
||||||
|
breachName: exposure.breachName,
|
||||||
|
exposedAt: exposure.exposedAt,
|
||||||
|
dataType: exposure.dataType,
|
||||||
|
severity: exposure.severity,
|
||||||
|
details: exposure.details || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
countExposures(userId: string): Promise<number> {
|
||||||
|
return prisma.exposure.count({
|
||||||
|
where: {
|
||||||
|
watchListItem: { userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
computeContentHash(dataSource: DataSource, breachName: string, watchListItemId: string): string {
|
||||||
|
const raw = `${dataSource}:${breachName}:${watchListItemId}`;
|
||||||
|
return createHash("sha256").update(raw).digest("hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
88
services/darkwatch/src/scanner/ScanService.ts
Normal file
88
services/darkwatch/src/scanner/ScanService.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import prisma from "@shieldai/db";
|
||||||
|
import { WatchListService } from "./watchlist/WatchListService";
|
||||||
|
import { HIBPService } from "./hibp/HIBPService";
|
||||||
|
import { MatchingEngine } from "./matching/MatchingEngine";
|
||||||
|
import { AlertPipeline } from "./alerts/AlertPipeline";
|
||||||
|
import { DataSource, ScanJobStatus } from "@shieldai/types";
|
||||||
|
|
||||||
|
export class ScanService {
|
||||||
|
private watchList: WatchListService;
|
||||||
|
private hibp: HIBPService;
|
||||||
|
private matching: MatchingEngine;
|
||||||
|
private alerts: AlertPipeline;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.watchList = new WatchListService();
|
||||||
|
this.hibp = new HIBPService(process.env.HIBP_API_KEY);
|
||||||
|
this.matching = new MatchingEngine();
|
||||||
|
this.alerts = new AlertPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async runScan(userId: string, source?: DataSource): Promise<number> {
|
||||||
|
const job = await prisma.scanJob.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
status: ScanJobStatus.RUNNING,
|
||||||
|
source: source || DataSource.HIBP,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let resultCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeItems = await this.watchList.getActiveItems(userId);
|
||||||
|
|
||||||
|
for (const item of activeItems) {
|
||||||
|
if (item.identifierType === "EMAIL") {
|
||||||
|
const breaches = await this.hibp.checkEmail(item.identifierValue);
|
||||||
|
for (const breach of breaches) {
|
||||||
|
const severity = this.hibp.getSeverity(breach);
|
||||||
|
const matched = await this.matching.matchExposure(
|
||||||
|
item.id,
|
||||||
|
DataSource.HIBP,
|
||||||
|
breach.name,
|
||||||
|
breach.breachDate,
|
||||||
|
breach.dataClasses,
|
||||||
|
severity,
|
||||||
|
`Breach: ${breach.title}. Domain: ${breach.domain}. PwnCount: ${breach.pwnCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
resultCount++;
|
||||||
|
await this.alerts.createAlert(userId, matched.dataSource + ":" + breach.name, severity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.scanJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: ScanJobStatus.COMPLETED,
|
||||||
|
resultCount,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await prisma.scanJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: ScanJobStatus.FAILED,
|
||||||
|
errorMessage: message,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScanHistory(userId: string, limit = 20) {
|
||||||
|
return prisma.scanJob.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
94
services/darkwatch/src/watchlist/WatchListService.ts
Normal file
94
services/darkwatch/src/watchlist/WatchListService.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import prisma from "@shieldai/db";
|
||||||
|
import { IdentifierType, WatchListStatus } from "@shieldai/types";
|
||||||
|
import { createHash, randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export class WatchListService {
|
||||||
|
async addItem(userId: string, identifierType: IdentifierType, identifierValue: string) {
|
||||||
|
const normalized = this.normalize(identifierType, identifierValue);
|
||||||
|
const hash = this.computeHash(normalized);
|
||||||
|
|
||||||
|
const existing = await prisma.watchListItem.findFirst({
|
||||||
|
where: { userId, identifierHash: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Identifier already watched: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = await this.getUserTier(userId);
|
||||||
|
await this.enforceLimit(userId, tier);
|
||||||
|
|
||||||
|
return prisma.watchListItem.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
identifierType,
|
||||||
|
identifierValue: normalized,
|
||||||
|
identifierHash: hash,
|
||||||
|
status: WatchListStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listItems(userId: string) {
|
||||||
|
return prisma.watchListItem.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(userId: string, itemId: string) {
|
||||||
|
return prisma.watchListItem.deleteMany({
|
||||||
|
where: { id: itemId, userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveItems(userId: string) {
|
||||||
|
return prisma.watchListItem.findMany({
|
||||||
|
where: { userId, status: WatchListStatus.ACTIVE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(itemId: string) {
|
||||||
|
return prisma.watchListItem.findUnique({ where: { id: itemId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(type: IdentifierType, value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (type === IdentifierType.EMAIL) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
if (type === IdentifierType.PHONE) {
|
||||||
|
return this.normalizePhone(trimmed);
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\s/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePhone(phone: string): string {
|
||||||
|
const digits = phone.replace(/\D/g, "");
|
||||||
|
if (digits.length === 10 && digits[0] !== "1") {
|
||||||
|
return `+1${digits}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 11 && digits[0] === "1") {
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeHash(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserTier(userId: string) {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
return user?.subscriptionTier ?? "BASIC";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enforceLimit(userId: string, tier: string) {
|
||||||
|
const count = await prisma.watchListItem.count({ where: { userId } });
|
||||||
|
const limits: Record<string, number> = { BASIC: 2, PLUS: 10, PREMIUM: 999 };
|
||||||
|
const limit = limits[tier] ?? 2;
|
||||||
|
if (count >= limit) {
|
||||||
|
throw new Error(`Watch list limit reached (${count}/${limit}) for tier ${tier}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
services/darkwatch/test/alerts.test.ts
Normal file
81
services/darkwatch/test/alerts.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { AlertPipeline } from "../src/alerts/AlertPipeline";
|
||||||
|
import prisma from "@shieldai/db";
|
||||||
|
import { Severity } from "@shieldai/types";
|
||||||
|
|
||||||
|
describe("AlertPipeline", () => {
|
||||||
|
let pipeline: AlertPipeline;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pipeline = new AlertPipeline();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates alert with dedup key", async () => {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: `alert-test-${Date.now()}@shieldai.local`, subscriptionTier: "BASIC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await prisma.watchListItem.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
identifierType: "EMAIL",
|
||||||
|
identifierValue: "test@example.com",
|
||||||
|
identifierHash: "hash-" + Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exposure = await prisma.exposure.create({
|
||||||
|
data: {
|
||||||
|
watchListItemId: item.id,
|
||||||
|
dataSource: "HIBP",
|
||||||
|
breachName: "TestBreach",
|
||||||
|
exposedAt: new Date(),
|
||||||
|
dataType: ["Email Address"],
|
||||||
|
severity: Severity.CRITICAL,
|
||||||
|
contentHash: "content-" + Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await pipeline.createAlert(user.id, exposure.id, Severity.CRITICAL);
|
||||||
|
expect(created).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates alerts within window", async () => {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: `dedup-test-${Date.now()}@shieldai.local`, subscriptionTier: "BASIC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = await prisma.watchListItem.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
identifierType: "EMAIL",
|
||||||
|
identifierValue: "dedup@example.com",
|
||||||
|
identifierHash: "dedup-hash-" + Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exposure = await prisma.exposure.create({
|
||||||
|
data: {
|
||||||
|
watchListItemId: item.id,
|
||||||
|
dataSource: "HIBP",
|
||||||
|
breachName: "DedupBreach",
|
||||||
|
exposedAt: new Date(),
|
||||||
|
dataType: ["Email Address"],
|
||||||
|
severity: Severity.CRITICAL,
|
||||||
|
contentHash: "dedup-content-" + Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await pipeline.createAlert(user.id, exposure.id, Severity.CRITICAL);
|
||||||
|
const second = await pipeline.createAlert(user.id, exposure.id, Severity.CRITICAL);
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes consistent dedup keys", () => {
|
||||||
|
const key1 = pipeline.computeDedupKey("user-1", "exposure-1");
|
||||||
|
const key2 = pipeline.computeDedupKey("user-1", "exposure-1");
|
||||||
|
expect(key1).toBe(key2);
|
||||||
|
expect(key1).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
services/darkwatch/test/hibp.test.ts
Normal file
62
services/darkwatch/test/hibp.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { HIBPService } from "../src/hibp/HIBPService";
|
||||||
|
import { Severity } from "@shieldai/types";
|
||||||
|
|
||||||
|
describe("HIBPService", () => {
|
||||||
|
const hibp = new HIBPService();
|
||||||
|
|
||||||
|
it("computes severity for critical data classes", () => {
|
||||||
|
const breach = {
|
||||||
|
name: "TestBreach",
|
||||||
|
title: "Test Breach",
|
||||||
|
domain: "test.com",
|
||||||
|
loginCount: 0,
|
||||||
|
passwordCount: 0,
|
||||||
|
date: new Date(),
|
||||||
|
breachDate: new Date(),
|
||||||
|
addedDate: new Date(),
|
||||||
|
pwnCount: 1000,
|
||||||
|
dataClasses: ["Password", "Email Address"],
|
||||||
|
logo: "",
|
||||||
|
};
|
||||||
|
expect(hibp.getSeverity(breach)).toBe(Severity.CRITICAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes severity for warning data classes", () => {
|
||||||
|
const breach = {
|
||||||
|
name: "TestBreach",
|
||||||
|
title: "Test Breach",
|
||||||
|
domain: "test.com",
|
||||||
|
loginCount: 0,
|
||||||
|
passwordCount: 0,
|
||||||
|
date: new Date(),
|
||||||
|
breachDate: new Date(),
|
||||||
|
addedDate: new Date(),
|
||||||
|
pwnCount: 1000,
|
||||||
|
dataClasses: ["Phone Number"],
|
||||||
|
logo: "",
|
||||||
|
};
|
||||||
|
expect(hibp.getSeverity(breach)).toBe(Severity.WARNING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes INFO for old non-critical breaches", () => {
|
||||||
|
const breach = {
|
||||||
|
name: "OldBreach",
|
||||||
|
title: "Old Breach",
|
||||||
|
domain: "old.com",
|
||||||
|
loginCount: 0,
|
||||||
|
passwordCount: 0,
|
||||||
|
date: new Date(),
|
||||||
|
breachDate: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
|
||||||
|
addedDate: new Date(),
|
||||||
|
pwnCount: 1000,
|
||||||
|
dataClasses: ["Name"],
|
||||||
|
logo: "",
|
||||||
|
};
|
||||||
|
expect(hibp.getSeverity(breach)).toBe(Severity.INFO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps to HIBP data source", () => {
|
||||||
|
expect(hibp.mapToDataSource()).toBe("HIBP");
|
||||||
|
});
|
||||||
|
});
|
||||||
20
services/darkwatch/test/matching.test.ts
Normal file
20
services/darkwatch/test/matching.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { MatchingEngine } from "../src/matching/MatchingEngine";
|
||||||
|
import { DataSource } from "@shieldai/types";
|
||||||
|
|
||||||
|
describe("MatchingEngine", () => {
|
||||||
|
const engine = new MatchingEngine();
|
||||||
|
|
||||||
|
it("computes consistent content hash", () => {
|
||||||
|
const hash1 = engine.computeContentHash(DataSource.HIBP, "TestBreach", "item-123");
|
||||||
|
const hash2 = engine.computeContentHash(DataSource.HIBP, "TestBreach", "item-123");
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
expect(hash1).toHaveLength(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces different hashes for different inputs", () => {
|
||||||
|
const hash1 = engine.computeContentHash(DataSource.HIBP, "BreachA", "item-123");
|
||||||
|
const hash2 = engine.computeContentHash(DataSource.HIBP, "BreachB", "item-123");
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
services/darkwatch/test/watchlist.test.ts
Normal file
76
services/darkwatch/test/watchlist.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { WatchListService } from "../src/watchlist/WatchListService";
|
||||||
|
import prisma from "@shieldai/db";
|
||||||
|
import { IdentifierType } from "@shieldai/types";
|
||||||
|
|
||||||
|
let runId = Date.now();
|
||||||
|
|
||||||
|
describe("WatchListService", () => {
|
||||||
|
let service: WatchListService;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
runId = Date.now();
|
||||||
|
service = new WatchListService();
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `test-${runId}@shieldai.local`,
|
||||||
|
name: "Test User",
|
||||||
|
subscriptionTier: "PREMIUM",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.watchListItem.deleteMany({ where: { userId } });
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an email identifier", async () => {
|
||||||
|
const item = await service.addItem(userId, IdentifierType.EMAIL, `test-${runId}@example.com`);
|
||||||
|
expect(item.identifierValue).toBe(`test-${runId}@example.com`);
|
||||||
|
expect(item.identifierType).toBe(IdentifierType.EMAIL);
|
||||||
|
expect(item.identifierHash).toHaveLength(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a phone identifier", async () => {
|
||||||
|
const digits = String(runId).padStart(10, "0").slice(-10);
|
||||||
|
const phone = `${digits.slice(0,3)}-${digits.slice(3,7)}-${digits.slice(7)}`;
|
||||||
|
const item = await service.addItem(userId, IdentifierType.PHONE, phone);
|
||||||
|
expect(item.identifierType).toBe(IdentifierType.PHONE);
|
||||||
|
expect(item.identifierValue).toMatch(/^\+1\d{10}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates by hash", async () => {
|
||||||
|
await service.addItem(userId, IdentifierType.EMAIL, `dedup-${runId}@example.com`);
|
||||||
|
const duplicate = service.addItem(userId, IdentifierType.EMAIL, `DEDUP-${runId}@EXAMPLE.COM`);
|
||||||
|
await expect(duplicate).rejects.toThrow("Identifier already watched");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists items", async () => {
|
||||||
|
await service.addItem(userId, IdentifierType.EMAIL, `list-a-${runId}@example.com`);
|
||||||
|
await service.addItem(userId, IdentifierType.EMAIL, `list-b-${runId}@example.com`);
|
||||||
|
const items = await service.listItems(userId);
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an item", async () => {
|
||||||
|
const item = await service.addItem(userId, IdentifierType.EMAIL, `remove-${runId}@example.com`);
|
||||||
|
const result = await service.removeItem(userId, item.id);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
const remaining = await service.listItems(userId);
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces BASIC tier limit", async () => {
|
||||||
|
const basicUser = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { subscriptionTier: "BASIC" },
|
||||||
|
});
|
||||||
|
await service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-1-${runId}@example.com`);
|
||||||
|
await service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-2-${runId}@example.com`);
|
||||||
|
const third = service.addItem(basicUser.id, IdentifierType.EMAIL, `basic-3-${runId}@example.com`);
|
||||||
|
await expect(third).rejects.toThrow("Watch list limit reached");
|
||||||
|
});
|
||||||
|
});
|
||||||
8
services/darkwatch/tsconfig.json
Normal file
8
services/darkwatch/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
26
turbo.json
Normal file
26
turbo.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"inputs": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"inputs": ["src/**/*.ts"]
|
||||||
|
},
|
||||||
|
"db:migrate": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"db:seed": {
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user