This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=unfi_agent
|
||||
POSTGRES_USER=unfi_agent
|
||||
POSTGRES_PASSWORD=unfi_agent
|
||||
DATABASE_URL=postgres://unfi_agent:unfi_agent@localhost:5432/unfi_agent
|
||||
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
API_PORT=8080
|
||||
WEB_PORT=5173
|
||||
WORKER_POLL_SECONDS=10
|
||||
API_INTERNAL_URL=http://localhost:8080
|
||||
|
||||
UDM_BASE_URL=https://udm-pro-se.local
|
||||
UDM_SITE=default
|
||||
UDM_USERNAME=automation-admin
|
||||
UDM_PASSWORD=change-me
|
||||
UDM_VERIFY_TLS=false
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-5.3-codex
|
||||
|
||||
ENCRYPTION_KEY_PATH=/run/secrets/unfi_encryption_key
|
||||
JWT_SECRET=replace-with-strong-secret
|
||||
MFA_ISSUER=UNFI-Security-Copilot
|
||||
|
||||
SMTP_HOST=mail.local
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
ALERT_FROM=unfi-agent@local
|
||||
ALERT_TO=security@local
|
||||
|
||||
BURN_IN_DAYS=7
|
||||
LAN_CIDR=192.168.0.0/16
|
||||
WORKER_SHARED_SECRET=local-worker-secret
|
||||
FIRMWARE_CHANNEL_POLICY=stable_only
|
||||
21
.github/workflows/ci.yml
vendored
Normal file
21
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm install
|
||||
- run: npm run typecheck
|
||||
- run: npm run test
|
||||
- run: npm run build
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
secrets
|
||||
.DS_Store
|
||||
*.log
|
||||
apps/*/node_modules
|
||||
apps/*/dist
|
||||
packages/*/node_modules
|
||||
packages/*/dist
|
||||
apps/api/.data
|
||||
apps/api/.tmp
|
||||
apps/worker/.tmp
|
||||
tmp
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# UNFI UDM Pro SE Security Copilot
|
||||
|
||||
LAN-only TypeScript monorepo for:
|
||||
- realtime UDM Pro SE log/event visibility
|
||||
- policy-based security posture auditing
|
||||
- GPT-5.3-Codex-gated recommendations
|
||||
- queued low-risk remediation with manual apply, backup, verify, rollback
|
||||
|
||||
## Monorepo layout
|
||||
- `apps/api` - Fastify HTTP + WebSocket API
|
||||
- `apps/worker` - background posture/recommendation/execution loops
|
||||
- `apps/web` - React dashboard
|
||||
- `packages/contracts` - shared zod schemas + types
|
||||
|
||||
## Quick start
|
||||
1. Copy `.env.example` to `.env` and fill secrets.
|
||||
2. Create an encryption key file (32 bytes):
|
||||
```bash
|
||||
mkdir -p secrets
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" > secrets/unfi_encryption_key
|
||||
```
|
||||
3. Set `ENCRYPTION_KEY_PATH` in `.env` to that file path.
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
4. Start with Docker:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
5. Or start services locally:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## First-run auth flow
|
||||
1. Open web UI at `http://localhost:5173`.
|
||||
2. Use **Bootstrap owner** once to create the first local account and get TOTP URI.
|
||||
3. Add TOTP URI to authenticator app.
|
||||
4. Sign in with username/password/TOTP code.
|
||||
|
||||
## Security defaults
|
||||
- AI/remediation features are blocked if `gpt-5.3-codex` is unavailable.
|
||||
- Queue execution is manual-trigger only (`POST /api/v1/remediation/queue/apply`).
|
||||
- Low-risk non-disruptive actions only.
|
||||
- Mandatory backup and rollback attempt for each execution.
|
||||
- Burn-in gate of 7 days before execution enablement.
|
||||
|
||||
## API endpoints
|
||||
- `POST /api/v1/udm/connect`
|
||||
- `GET /api/v1/health/dependencies`
|
||||
- `GET /api/v1/logs/realtime` (WebSocket upgrade)
|
||||
- `GET /api/v1/security/posture`
|
||||
- `GET /api/v1/security/recommendations`
|
||||
- `POST /api/v1/remediation/queue`
|
||||
- `POST /api/v1/remediation/queue/apply`
|
||||
- `GET /api/v1/remediation/executions/:id`
|
||||
- `GET /api/v1/audit/events`
|
||||
- `POST /api/v1/alerts/test-email`
|
||||
|
||||
## Notes
|
||||
- MVP targets one UDM Pro SE on UniFi stable channel.
|
||||
- Syslog fallback listens on UDP 5514 when enabled by the worker.
|
||||
20
apps/api/Dockerfile
Normal file
20
apps/api/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /workspace
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/tsconfig.json ./packages/contracts/
|
||||
COPY packages/contracts/src ./packages/contracts/src
|
||||
COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/
|
||||
COPY apps/api/src ./apps/api/src
|
||||
RUN npm install
|
||||
RUN npm run -w @unfi/contracts build
|
||||
RUN npm run -w @unfi/api build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /workspace/package.json ./
|
||||
COPY --from=build /workspace/node_modules ./node_modules
|
||||
COPY --from=build /workspace/packages/contracts ./packages/contracts
|
||||
COPY --from=build /workspace/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=build /workspace/apps/api/dist ./apps/api/dist
|
||||
CMD ["node", "apps/api/dist/src/server.js"]
|
||||
43
apps/api/package.json
Normal file
43
apps/api/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@unfi/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/src/server.js",
|
||||
"types": "dist/src/server.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json && node ./scripts/copy-sql.mjs",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node dist/src/server.js",
|
||||
"lint": "echo \"No linter configured for api\"",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@unfi/contracts": "0.1.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^17.2.2",
|
||||
"fastify": "^5.6.0",
|
||||
"ioredis": "^5.8.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.6",
|
||||
"openai": "^5.20.3",
|
||||
"otplib": "^12.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pg": "^8.15.5",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
10
apps/api/scripts/copy-sql.mjs
Normal file
10
apps/api/scripts/copy-sql.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { cp, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = join(here, "..", "src", "sql");
|
||||
const dest = join(here, "..", "dist", "sql");
|
||||
|
||||
await mkdir(dest, { recursive: true });
|
||||
await cp(src, dest, { recursive: true });
|
||||
46
apps/api/src/config.ts
Normal file
46
apps/api/src/config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { config as loadDotEnv } from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
loadDotEnv();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
API_PORT: z.coerce.number().default(8080),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url(),
|
||||
UDM_BASE_URL: z.string().url(),
|
||||
UDM_SITE: z.string().default("default"),
|
||||
UDM_USERNAME: z.string().min(1),
|
||||
UDM_PASSWORD: z.string().min(1),
|
||||
UDM_VERIFY_TLS: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((value) => value.toLowerCase() === "true"),
|
||||
OPENAI_API_KEY: z.string().optional().default(""),
|
||||
OPENAI_BASE_URL: z.string().url().default("https://api.openai.com/v1"),
|
||||
OPENAI_MODEL: z.string().default("gpt-5.3-codex"),
|
||||
ENCRYPTION_KEY_PATH: z.string().min(1),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
MFA_ISSUER: z.string().default("UNFI-Security-Copilot"),
|
||||
SMTP_HOST: z.string().min(1),
|
||||
SMTP_PORT: z.coerce.number().default(587),
|
||||
SMTP_SECURE: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((value) => value.toLowerCase() === "true"),
|
||||
SMTP_USER: z.string().optional().default(""),
|
||||
SMTP_PASSWORD: z.string().optional().default(""),
|
||||
ALERT_FROM: z.string().min(1),
|
||||
ALERT_TO: z.string().min(1),
|
||||
BURN_IN_DAYS: z.coerce.number().int().positive().default(7),
|
||||
LAN_CIDR: z.string().default("192.168.0.0/16"),
|
||||
WORKER_SHARED_SECRET: z.string().default("local-worker-secret"),
|
||||
FIRMWARE_CHANNEL_POLICY: z.enum(["stable_only", "allow_early_access"]).default("stable_only")
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
return envSchema.parse(env);
|
||||
}
|
||||
54
apps/api/src/lib/audit.ts
Normal file
54
apps/api/src/lib/audit.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { AuditEvent } from "@unfi/contracts";
|
||||
import type { Repository } from "../services/repository.js";
|
||||
|
||||
type AuditInput = Omit<AuditEvent, "id" | "occurredAt" | "prevHash" | "hash">;
|
||||
|
||||
export class AuditLedger {
|
||||
constructor(private readonly repository: Repository) {}
|
||||
|
||||
async append(input: AuditInput): Promise<AuditEvent> {
|
||||
const previous = await this.repository.getLastAuditEvent();
|
||||
const occurredAt = new Date().toISOString();
|
||||
const id = uuid();
|
||||
const prevHash = previous?.hash ?? null;
|
||||
const hash = this.computeHash({
|
||||
id,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
actor: input.actor,
|
||||
actorRole: input.actorRole,
|
||||
eventType: input.eventType,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
payload: input.payload
|
||||
});
|
||||
|
||||
const event: AuditEvent = {
|
||||
id,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
hash,
|
||||
...input
|
||||
};
|
||||
|
||||
await this.repository.insertAuditEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
private computeHash(input: {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
prevHash: string | null;
|
||||
actor: string;
|
||||
actorRole: AuditEvent["actorRole"];
|
||||
eventType: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): string {
|
||||
const serialized = JSON.stringify(input);
|
||||
return createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
}
|
||||
114
apps/api/src/lib/auth.ts
Normal file
114
apps/api/src/lib/auth.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { authenticator } from "otplib";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { EncryptionService } from "./crypto.js";
|
||||
import type { Repository } from "../services/repository.js";
|
||||
|
||||
export type AppRole = "owner" | "operator" | "viewer";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: AppRole;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly repository: Repository,
|
||||
private readonly encryption: EncryptionService,
|
||||
private readonly jwtSecret: string,
|
||||
private readonly mfaIssuer: string
|
||||
) {}
|
||||
|
||||
async bootstrapOwner(username: string, password: string): Promise<{ username: string; otpProvisioningUri: string }> {
|
||||
const existingUsers = await this.repository.countUsers();
|
||||
if (existingUsers > 0) {
|
||||
throw new Error("Bootstrap is only allowed when no users exist");
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const totpSecret = authenticator.generateSecret();
|
||||
const userId = uuid();
|
||||
|
||||
await this.repository.insertUser({
|
||||
id: userId,
|
||||
username,
|
||||
passwordHash,
|
||||
role: "owner",
|
||||
totpSecretEncrypted: this.encryption.encrypt(totpSecret)
|
||||
});
|
||||
|
||||
const otpProvisioningUri = authenticator.keyuri(username, this.mfaIssuer, totpSecret);
|
||||
return { username, otpProvisioningUri };
|
||||
}
|
||||
|
||||
async login(username: string, password: string, mfaCode: string): Promise<{ token: string; user: AuthenticatedUser }> {
|
||||
const user = await this.repository.getUserByUsername(username);
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
const passwordOk = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
const decryptedTotpSecret = this.encryption.decrypt(user.totpSecretEncrypted);
|
||||
const mfaOk = authenticator.check(mfaCode, decryptedTotpSecret);
|
||||
if (!mfaOk) {
|
||||
throw new Error("Invalid MFA code");
|
||||
}
|
||||
|
||||
const payload: AuthenticatedUser = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, this.jwtSecret, {
|
||||
expiresIn: "8h",
|
||||
issuer: "unfi-agent"
|
||||
});
|
||||
|
||||
return { token, user: payload };
|
||||
}
|
||||
|
||||
verifyToken(token: string): AuthenticatedUser {
|
||||
return jwt.verify(token, this.jwtSecret, {
|
||||
issuer: "unfi-agent"
|
||||
}) as AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
export function roleAtLeast(role: AppRole, minimumRole: AppRole): boolean {
|
||||
const order: AppRole[] = ["viewer", "operator", "owner"];
|
||||
return order.indexOf(role) >= order.indexOf(minimumRole);
|
||||
}
|
||||
|
||||
export function extractBearerToken(request: FastifyRequest): string | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) {
|
||||
const queryToken =
|
||||
typeof (request.query as { token?: unknown } | undefined)?.token === "string"
|
||||
? ((request.query as { token?: string }).token ?? null)
|
||||
: null;
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const [scheme, token] = authHeader.split(" ");
|
||||
if (scheme?.toLowerCase() !== "bearer" || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function unauthorized(reply: FastifyReply, message = "Unauthorized"): void {
|
||||
reply.status(401).send({ error: message });
|
||||
}
|
||||
|
||||
export function forbidden(reply: FastifyReply, message = "Forbidden"): void {
|
||||
reply.status(403).send({ error: message });
|
||||
}
|
||||
53
apps/api/src/lib/crypto.ts
Normal file
53
apps/api/src/lib/crypto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(keyPath: string) {
|
||||
const fileContent = readFileSync(keyPath, "utf8").trim();
|
||||
const asBuffer = this.resolveKey(fileContent);
|
||||
if (asBuffer.length !== 32) {
|
||||
throw new Error("Encryption key must be exactly 32 bytes");
|
||||
}
|
||||
|
||||
this.key = asBuffer;
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`;
|
||||
}
|
||||
|
||||
decrypt(payload: string): string {
|
||||
const [ivBase64, tagBase64, encryptedBase64] = payload.split(".");
|
||||
if (!ivBase64 || !tagBase64 || !encryptedBase64) {
|
||||
throw new Error("Invalid encrypted payload format");
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivBase64, "base64");
|
||||
const tag = Buffer.from(tagBase64, "base64");
|
||||
const encrypted = Buffer.from(encryptedBase64, "base64");
|
||||
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
|
||||
private resolveKey(raw: string): Buffer {
|
||||
try {
|
||||
const fromBase64 = Buffer.from(raw, "base64");
|
||||
if (fromBase64.length === 32) {
|
||||
return fromBase64;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return Buffer.from(raw, "utf8");
|
||||
}
|
||||
}
|
||||
23
apps/api/src/lib/db.ts
Normal file
23
apps/api/src/lib/db.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from "pg";
|
||||
|
||||
export class Database {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({
|
||||
connectionString: databaseUrl
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<PoolClient> {
|
||||
return this.pool.connect();
|
||||
}
|
||||
|
||||
async query<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<QueryResult<T>> {
|
||||
return this.pool.query<T>(sql, params);
|
||||
}
|
||||
|
||||
async end(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
26
apps/api/src/lib/migrations.ts
Normal file
26
apps/api/src/lib/migrations.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Database } from "./db.js";
|
||||
|
||||
const MIGRATION_SQL = "001_init.sql";
|
||||
|
||||
export async function runMigrations(db: Database): Promise<void> {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const candidatePaths = [join(here, "..", "sql", MIGRATION_SQL), join(here, "..", "..", "sql", MIGRATION_SQL)];
|
||||
const sqlPath = await resolveExistingPath(candidatePaths);
|
||||
const sql = await readFile(sqlPath, "utf-8");
|
||||
await db.query(sql);
|
||||
}
|
||||
|
||||
async function resolveExistingPath(paths: string[]): Promise<string> {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await access(path);
|
||||
return path;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
throw new Error(`Migration SQL not found. Checked: ${paths.join(", ")}`);
|
||||
}
|
||||
383
apps/api/src/server.ts
Normal file
383
apps/api/src/server.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import websocket from "@fastify/websocket";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
remediationApplyRequestSchema,
|
||||
remediationQueueRequestSchema,
|
||||
udmConnectRequestSchema
|
||||
} from "@unfi/contracts";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { AuditLedger } from "./lib/audit.js";
|
||||
import { AuthService, extractBearerToken, forbidden, roleAtLeast, unauthorized, type AppRole } from "./lib/auth.js";
|
||||
import { EncryptionService } from "./lib/crypto.js";
|
||||
import { Database } from "./lib/db.js";
|
||||
import { runMigrations } from "./lib/migrations.js";
|
||||
import { AlertService } from "./services/alertService.js";
|
||||
import { DependencyService } from "./services/dependencyService.js";
|
||||
import { CodexClient } from "./services/llmOrchestrator.js";
|
||||
import { LogIngestor } from "./services/logIngestor.js";
|
||||
import { PolicyEngine } from "./services/policyEngine.js";
|
||||
import { RemediationManager } from "./services/remediationManager.js";
|
||||
import { Repository } from "./services/repository.js";
|
||||
import { SecurityPipelineService } from "./services/securityPipeline.js";
|
||||
import { UnifiHttpAdapter } from "./services/unifiAdapter.js";
|
||||
|
||||
const bootstrapSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
password: z.string().min(12)
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
mfaCode: z.string().min(6)
|
||||
});
|
||||
|
||||
const config = loadConfig();
|
||||
const db = new Database(config.DATABASE_URL);
|
||||
await runMigrations(db);
|
||||
|
||||
const repository = new Repository(db);
|
||||
const encryption = new EncryptionService(config.ENCRYPTION_KEY_PATH);
|
||||
const audit = new AuditLedger(repository);
|
||||
const authService = new AuthService(repository, encryption, config.JWT_SECRET, config.MFA_ISSUER);
|
||||
const unifiAdapter = new UnifiHttpAdapter(async () => {
|
||||
const connection = await repository.getUdmConnection();
|
||||
if (!connection) {
|
||||
return {
|
||||
baseUrl: config.UDM_BASE_URL,
|
||||
site: config.UDM_SITE,
|
||||
username: config.UDM_USERNAME,
|
||||
password: config.UDM_PASSWORD,
|
||||
verifyTls: config.UDM_VERIFY_TLS
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseUrl: connection.baseUrl,
|
||||
site: connection.site,
|
||||
username: connection.username,
|
||||
password: encryption.decrypt(connection.passwordEncrypted),
|
||||
verifyTls: connection.verifyTls
|
||||
};
|
||||
}, config.FIRMWARE_CHANNEL_POLICY);
|
||||
const logIngestor = new LogIngestor(repository, unifiAdapter);
|
||||
await logIngestor.startSyslogListener(5514);
|
||||
|
||||
const codexClient = new CodexClient(config.OPENAI_API_KEY, config.OPENAI_BASE_URL, config.OPENAI_MODEL);
|
||||
const policyEngine = new PolicyEngine();
|
||||
const dependencyService = new DependencyService(unifiAdapter, codexClient);
|
||||
const alertService = new AlertService(repository, {
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_SECURE,
|
||||
user: config.SMTP_USER,
|
||||
password: config.SMTP_PASSWORD,
|
||||
from: config.ALERT_FROM,
|
||||
to: config.ALERT_TO
|
||||
});
|
||||
const remediationManager = new RemediationManager(
|
||||
repository,
|
||||
unifiAdapter,
|
||||
dependencyService,
|
||||
audit,
|
||||
alertService,
|
||||
config.BURN_IN_DAYS
|
||||
);
|
||||
const securityPipeline = new SecurityPipelineService(repository, unifiAdapter, policyEngine, codexClient, audit);
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.LOG_LEVEL
|
||||
}
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true
|
||||
});
|
||||
await app.register(websocket);
|
||||
|
||||
const ensureRole = (minimumRole: AppRole) => {
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
const token = extractBearerToken(request);
|
||||
if (!token) {
|
||||
return unauthorized(reply);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = authService.verifyToken(token);
|
||||
request.user = user;
|
||||
if (!roleAtLeast(user.role, minimumRole)) {
|
||||
return forbidden(reply);
|
||||
}
|
||||
} catch {
|
||||
return unauthorized(reply);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const ensureWorkerSecret = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
const secret = request.headers["x-worker-secret"];
|
||||
if (secret !== config.WORKER_SHARED_SECRET) {
|
||||
unauthorized(reply);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
app.get("/api/v1/health/dependencies", async () => {
|
||||
return dependencyService.check();
|
||||
});
|
||||
|
||||
app.post("/api/v1/auth/bootstrap", async (request, reply) => {
|
||||
const body = bootstrapSchema.parse(request.body);
|
||||
const result = await authService.bootstrapOwner(body.username, body.password);
|
||||
await audit.append({
|
||||
actor: body.username,
|
||||
actorRole: "owner",
|
||||
eventType: "auth.bootstrap",
|
||||
entityType: "user",
|
||||
entityId: body.username,
|
||||
payload: {}
|
||||
});
|
||||
return reply.status(201).send(result);
|
||||
});
|
||||
|
||||
app.post("/api/v1/auth/login", async (request) => {
|
||||
const body = loginSchema.parse(request.body);
|
||||
const session = await authService.login(body.username, body.password, body.mfaCode);
|
||||
await audit.append({
|
||||
actor: body.username,
|
||||
actorRole: session.user.role,
|
||||
eventType: "auth.login",
|
||||
entityType: "user",
|
||||
entityId: session.user.id,
|
||||
payload: {}
|
||||
});
|
||||
return session;
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/v1/udm/connect",
|
||||
{
|
||||
preHandler: ensureRole("owner")
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = udmConnectRequestSchema.parse(request.body);
|
||||
await repository.saveUdmConnection({
|
||||
baseUrl: body.baseUrl,
|
||||
site: body.site,
|
||||
username: body.username,
|
||||
passwordEncrypted: encryption.encrypt(body.password),
|
||||
verifyTls: body.verifyTls
|
||||
});
|
||||
await audit.append({
|
||||
actor: request.user!.username,
|
||||
actorRole: request.user!.role,
|
||||
eventType: "udm.connection.updated",
|
||||
entityType: "udm_connection",
|
||||
entityId: "singleton",
|
||||
payload: {
|
||||
baseUrl: body.baseUrl,
|
||||
site: body.site,
|
||||
verifyTls: body.verifyTls
|
||||
}
|
||||
});
|
||||
return reply.status(201).send({
|
||||
connected: true
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/logs/realtime",
|
||||
{
|
||||
preHandler: ensureRole("viewer"),
|
||||
websocket: true
|
||||
},
|
||||
(connection) => {
|
||||
const socket =
|
||||
typeof (connection as { send?: unknown }).send === "function"
|
||||
? (connection as { send: (payload: string) => void; on: (event: string, callback: () => void) => void })
|
||||
: ((connection as { socket: { send: (payload: string) => void; on: (event: string, callback: () => void) => void } }).socket);
|
||||
let active = true;
|
||||
let lastTimestamp = "1970-01-01T00:00:00.000Z";
|
||||
const interval = setInterval(async () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const events = await repository.getRecentEvents(200);
|
||||
const delta = events
|
||||
.filter((event) => event.timestamp > lastTimestamp)
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
if (delta.length > 0) {
|
||||
const latest = delta.at(-1);
|
||||
if (latest) {
|
||||
lastTimestamp = latest.timestamp;
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "events", payload: delta }));
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
socket.on("close", () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/security/posture",
|
||||
{
|
||||
preHandler: ensureRole("viewer")
|
||||
},
|
||||
async (_request, reply) => {
|
||||
const posture = await repository.getLatestPosture();
|
||||
if (!posture) {
|
||||
return reply.status(404).send({ error: "No posture data yet" });
|
||||
}
|
||||
return posture;
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/security/recommendations",
|
||||
{
|
||||
preHandler: ensureRole("viewer")
|
||||
},
|
||||
async () => {
|
||||
return repository.getRecommendations();
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/remediation/queue",
|
||||
{
|
||||
preHandler: ensureRole("operator")
|
||||
},
|
||||
async (request) => {
|
||||
const body = remediationQueueRequestSchema.parse(request.body);
|
||||
return remediationManager.enqueue(request.user!.username, body.actions);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/remediation/queue/apply",
|
||||
{
|
||||
preHandler: ensureRole("operator")
|
||||
},
|
||||
async (request, reply) => {
|
||||
remediationApplyRequestSchema.parse(request.body);
|
||||
const result = await remediationManager.applyQueue(request.user!.username);
|
||||
if (result.blockedReason) {
|
||||
return reply.status(409).send(result);
|
||||
}
|
||||
return reply.status(202).send(result);
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/remediation/executions/:id",
|
||||
{
|
||||
preHandler: ensureRole("viewer")
|
||||
},
|
||||
async (request, reply) => {
|
||||
const params = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||
const execution = await repository.getExecutionById(params.id);
|
||||
if (!execution) {
|
||||
return reply.status(404).send({ error: "Execution not found" });
|
||||
}
|
||||
return execution;
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/audit/events",
|
||||
{
|
||||
preHandler: ensureRole("viewer")
|
||||
},
|
||||
async () => {
|
||||
return repository.listAuditEvents(200);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/alerts/test-email",
|
||||
{
|
||||
preHandler: ensureRole("operator")
|
||||
},
|
||||
async (request) => {
|
||||
await alertService.sendEmail({
|
||||
subject: "UNFI Copilot SMTP Test",
|
||||
text: `Triggered by ${request.user?.username ?? "unknown"} at ${new Date().toISOString()}`
|
||||
});
|
||||
return { sent: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/notifications",
|
||||
{
|
||||
preHandler: ensureRole("viewer")
|
||||
},
|
||||
async () => {
|
||||
return repository.listNotifications(100);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/internal/jobs/ingest",
|
||||
{
|
||||
preHandler: ensureWorkerSecret
|
||||
},
|
||||
async () => {
|
||||
const inserted = await logIngestor.pollUnifiApiOnce();
|
||||
return { inserted };
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/internal/jobs/analyze",
|
||||
{
|
||||
preHandler: ensureWorkerSecret
|
||||
},
|
||||
async () => {
|
||||
const result = await securityPipeline.run();
|
||||
return {
|
||||
postureScore: result.posture.score,
|
||||
recommendationCount: result.recommendations.recommendations.length
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/internal/jobs/execute",
|
||||
{
|
||||
preHandler: ensureWorkerSecret
|
||||
},
|
||||
async () => {
|
||||
const processed = await remediationManager.processPendingQueue();
|
||||
return { processed };
|
||||
}
|
||||
);
|
||||
|
||||
app.get("/healthz", async () => ({ ok: true }));
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
logIngestor.stopSyslogListener();
|
||||
await db.end();
|
||||
};
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await close();
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({
|
||||
host: "0.0.0.0",
|
||||
port: config.API_PORT
|
||||
});
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
await close();
|
||||
process.exit(1);
|
||||
}
|
||||
60
apps/api/src/services/alertService.ts
Normal file
60
apps/api/src/services/alertService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import type { Repository } from "./repository.js";
|
||||
|
||||
export class AlertService {
|
||||
private readonly transporter;
|
||||
|
||||
constructor(
|
||||
private readonly repository: Repository,
|
||||
smtp: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
) {
|
||||
this.smtpFrom = smtp.from;
|
||||
this.smtpTo = smtp.to;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.user ? { user: smtp.user, pass: smtp.password } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
private readonly smtpFrom: string;
|
||||
private readonly smtpTo: string;
|
||||
|
||||
async notify(input: {
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
body: string;
|
||||
sendEmail?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.repository.addNotification({
|
||||
severity: input.severity,
|
||||
title: input.title,
|
||||
body: input.body
|
||||
});
|
||||
|
||||
if (input.sendEmail !== false) {
|
||||
await this.sendEmail({
|
||||
subject: `[${input.severity.toUpperCase()}] ${input.title}`,
|
||||
text: input.body
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(input: { subject: string; text: string }): Promise<void> {
|
||||
await this.transporter.sendMail({
|
||||
from: this.smtpFrom,
|
||||
to: this.smtpTo,
|
||||
subject: input.subject,
|
||||
text: input.text
|
||||
});
|
||||
}
|
||||
}
|
||||
40
apps/api/src/services/dependencyService.ts
Normal file
40
apps/api/src/services/dependencyService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { CapabilityReport } from "@unfi/contracts";
|
||||
import type { CodexClient } from "./llmOrchestrator.js";
|
||||
import type { UnifiAdapter } from "./unifiAdapter.js";
|
||||
|
||||
export interface DependencyHealth {
|
||||
checkedAt: string;
|
||||
modelGateReady: boolean;
|
||||
udm: CapabilityReport;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export class DependencyService {
|
||||
constructor(
|
||||
private readonly unifiAdapter: UnifiAdapter,
|
||||
private readonly codexClient: CodexClient
|
||||
) {}
|
||||
|
||||
async check(): Promise<DependencyHealth> {
|
||||
const issues: string[] = [];
|
||||
const udm = await this.unifiAdapter.probeCapabilities();
|
||||
|
||||
let modelGateReady = false;
|
||||
try {
|
||||
await this.codexClient.assertModelAvailable("gpt-5.3-codex");
|
||||
modelGateReady = true;
|
||||
} catch (error) {
|
||||
issues.push((error as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
modelGateReady,
|
||||
udm: {
|
||||
...udm,
|
||||
modelGateReady
|
||||
},
|
||||
issues: [...udm.issues, ...issues]
|
||||
};
|
||||
}
|
||||
}
|
||||
127
apps/api/src/services/llmOrchestrator.ts
Normal file
127
apps/api/src/services/llmOrchestrator.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { recommendationSetSchema, type Recommendation, type RecommendationSet, type SecurityAnalysisInput } from "@unfi/contracts";
|
||||
|
||||
const SECRET_KEY_PATTERN = /(password|secret|token|cookie|api[_-]?key|private[_-]?key)/i;
|
||||
|
||||
export class CodexClient {
|
||||
private availableModels = new Set<string>();
|
||||
private modelsCheckedAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly baseUrl: string,
|
||||
private readonly model: string
|
||||
) {}
|
||||
|
||||
async assertModelAvailable(requiredModel: "gpt-5.3-codex"): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const cacheTtlMs = 5 * 60 * 1000;
|
||||
if (Date.now() - this.modelsCheckedAt > cacheTtlMs) {
|
||||
await this.refreshModels();
|
||||
}
|
||||
|
||||
if (!this.availableModels.has(requiredModel)) {
|
||||
throw new Error(`Required model '${requiredModel}' is not available for this API key`);
|
||||
}
|
||||
}
|
||||
|
||||
async recommend(input: SecurityAnalysisInput, fallbackRecommendations: Recommendation[]): Promise<RecommendationSet> {
|
||||
await this.assertModelAvailable("gpt-5.3-codex");
|
||||
const sanitized = sanitizeForModel(input) as SecurityAnalysisInput;
|
||||
|
||||
const systemPrompt =
|
||||
"You are a network security copilot. Return JSON with shape {generatedAt, model, recommendations[]} only.";
|
||||
const userPrompt = JSON.stringify({
|
||||
task: "Recommend low-risk, reversible, non-disruptive hardening actions",
|
||||
constraints: [
|
||||
"Only include actions with riskLevel=low",
|
||||
"Never include disruptive=true",
|
||||
"Prefer explicit endpoint+body payloads for UniFi Network API"
|
||||
],
|
||||
analysis: sanitized
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.1
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI request failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string | null } }>;
|
||||
};
|
||||
const text = payload.choices?.[0]?.message?.content;
|
||||
if (!text) {
|
||||
throw new Error("OpenAI response did not include content");
|
||||
}
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
const normalized = recommendationSetSchema.parse(parsed);
|
||||
return normalized;
|
||||
} catch {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
model: "rule-fallback",
|
||||
recommendations: fallbackRecommendations.map((recommendation) => ({
|
||||
...recommendation,
|
||||
source: "rule",
|
||||
id: recommendation.id || uuid(),
|
||||
createdAt: recommendation.createdAt || new Date().toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshModels(): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/models`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${this.apiKey}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list models: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { data?: Array<{ id?: string }> };
|
||||
const ids = (payload.data ?? []).flatMap((model) => (typeof model.id === "string" ? [model.id] : []));
|
||||
this.availableModels = new Set(ids);
|
||||
this.modelsCheckedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeForModel(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeForModel(item));
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (SECRET_KEY_PATTERN.test(key)) {
|
||||
result[key] = "[REDACTED]";
|
||||
continue;
|
||||
}
|
||||
result[key] = sanitizeForModel(nested);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
80
apps/api/src/services/logIngestor.ts
Normal file
80
apps/api/src/services/logIngestor.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import dgram from "node:dgram";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { NormalizedEvent } from "@unfi/contracts";
|
||||
import type { Repository } from "./repository.js";
|
||||
import type { UnifiHttpAdapter } from "./unifiAdapter.js";
|
||||
|
||||
export class LogIngestor {
|
||||
private syslogStarted = false;
|
||||
private socket: dgram.Socket | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly repository: Repository,
|
||||
private readonly unifiAdapter: UnifiHttpAdapter
|
||||
) {}
|
||||
|
||||
async pollUnifiApiOnce(): Promise<number> {
|
||||
const events = await this.unifiAdapter.fetchRecentEvents();
|
||||
return this.repository.insertEvents(events);
|
||||
}
|
||||
|
||||
async startSyslogListener(port = 5514): Promise<void> {
|
||||
if (this.syslogStarted) {
|
||||
return;
|
||||
}
|
||||
this.syslogStarted = true;
|
||||
|
||||
this.socket = dgram.createSocket("udp4");
|
||||
this.socket.on("message", async (message, remote) => {
|
||||
const raw = message.toString("utf8").trim();
|
||||
const now = new Date().toISOString();
|
||||
const severity = mapSyslogSeverity(raw);
|
||||
const fingerprint = createHash("sha256").update(`${raw}|${now.slice(0, 16)}`).digest("hex");
|
||||
|
||||
const event: NormalizedEvent = {
|
||||
id: uuid(),
|
||||
timestamp: now,
|
||||
source: "syslog",
|
||||
severity,
|
||||
category: "syslog",
|
||||
message: raw,
|
||||
fingerprint,
|
||||
metadata: {
|
||||
remoteAddress: remote.address,
|
||||
remotePort: remote.port
|
||||
}
|
||||
};
|
||||
await this.repository.insertEvents([event]);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.socket?.bind(port, "0.0.0.0", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
stopSyslogListener(): void {
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
this.syslogStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mapSyslogSeverity(raw: string): NormalizedEvent["severity"] {
|
||||
const match = raw.match(/^<(\d+)>/);
|
||||
const pri = match ? Number(match[1]) : 13;
|
||||
const severity = pri % 8;
|
||||
if (severity <= 1) {
|
||||
return "critical";
|
||||
}
|
||||
if (severity <= 3) {
|
||||
return "error";
|
||||
}
|
||||
if (severity === 4) {
|
||||
return "warn";
|
||||
}
|
||||
if (severity >= 7) {
|
||||
return "debug";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
241
apps/api/src/services/policyEngine.ts
Normal file
241
apps/api/src/services/policyEngine.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { PolicyCheckResult, Recommendation, SecurityPosture, UdmConfigSnapshot } from "@unfi/contracts";
|
||||
|
||||
function getBooleanSetting(settings: Record<string, unknown>, path: string, fallback = false): boolean {
|
||||
const segments = path.split(".");
|
||||
let current: unknown = settings;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof current === "boolean" ? current : fallback;
|
||||
}
|
||||
|
||||
function getStringSetting(settings: Record<string, unknown>, path: string, fallback = ""): string {
|
||||
const segments = path.split(".");
|
||||
let current: unknown = settings;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof current === "string" ? current : fallback;
|
||||
}
|
||||
|
||||
function buildRecommendation(input: {
|
||||
controlId: string;
|
||||
title: string;
|
||||
rationale: string;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
actionType: "set_config" | "enable_feature" | "disable_feature" | "notify_only";
|
||||
payload: Record<string, unknown>;
|
||||
}): Recommendation {
|
||||
return {
|
||||
id: uuid(),
|
||||
source: "rule",
|
||||
title: input.title,
|
||||
rationale: input.rationale,
|
||||
riskLevel: input.riskLevel,
|
||||
controls: [input.controlId],
|
||||
actions: [
|
||||
{
|
||||
id: uuid(),
|
||||
controlId: input.controlId,
|
||||
type: input.actionType,
|
||||
description: input.title,
|
||||
riskLevel: input.riskLevel,
|
||||
disruptive: false,
|
||||
reversible: true,
|
||||
payload: input.payload,
|
||||
expectedState: (input.payload.expectedState as Record<string, unknown> | undefined) ?? {}
|
||||
}
|
||||
],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export class PolicyEngine {
|
||||
evaluate(snapshot: UdmConfigSnapshot, previousSnapshot: UdmConfigSnapshot | null): {
|
||||
posture: SecurityPosture;
|
||||
recommendations: Recommendation[];
|
||||
} {
|
||||
const settings = snapshot.settings;
|
||||
const checks: PolicyCheckResult[] = [];
|
||||
const recommendations: Recommendation[] = [];
|
||||
|
||||
const remoteMgmtEnabled = getBooleanSetting(settings, "raw.remoteAccess", false);
|
||||
checks.push({
|
||||
controlId: "MGMT-01",
|
||||
title: "Management plane exposure is minimized",
|
||||
description: "Remote management should be disabled or tightly controlled.",
|
||||
status: remoteMgmtEnabled ? "fail" : "pass",
|
||||
severity: "high",
|
||||
evidence: [remoteMgmtEnabled ? "remoteAccess=true" : "remoteAccess=false"],
|
||||
recommendedActionIds: []
|
||||
});
|
||||
if (remoteMgmtEnabled) {
|
||||
recommendations.push(
|
||||
buildRecommendation({
|
||||
controlId: "MGMT-01",
|
||||
title: "Disable remote management access",
|
||||
rationale: "Public management interfaces increase attack surface.",
|
||||
riskLevel: "low",
|
||||
actionType: "disable_feature",
|
||||
payload: {
|
||||
endpoint: "/proxy/network/api/s/default/set/setting/mgmt",
|
||||
method: "POST",
|
||||
body: { remoteAccess: false },
|
||||
expectedState: { remoteAccess: false }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const mfaEnabled = getBooleanSetting(settings, "raw.adminMfaRequired", false);
|
||||
checks.push({
|
||||
controlId: "AUTH-01",
|
||||
title: "Admin MFA is required",
|
||||
description: "Administrator accounts should require MFA.",
|
||||
status: mfaEnabled ? "pass" : "fail",
|
||||
severity: "high",
|
||||
evidence: [mfaEnabled ? "adminMfaRequired=true" : "adminMfaRequired=false"],
|
||||
recommendedActionIds: []
|
||||
});
|
||||
|
||||
const syslogEnabled = getBooleanSetting(settings, "raw.syslogEnabled", false);
|
||||
checks.push({
|
||||
controlId: "LOG-01",
|
||||
title: "Logging and retention are enabled",
|
||||
description: "Security logs must be enabled and exported for durable retention.",
|
||||
status: syslogEnabled ? "pass" : "warn",
|
||||
severity: "medium",
|
||||
evidence: [syslogEnabled ? "syslogEnabled=true" : "syslogEnabled=false"],
|
||||
recommendedActionIds: []
|
||||
});
|
||||
if (!syslogEnabled) {
|
||||
recommendations.push(
|
||||
buildRecommendation({
|
||||
controlId: "LOG-01",
|
||||
title: "Enable syslog forwarding",
|
||||
rationale: "Syslog fallback supports complete event coverage and retention.",
|
||||
riskLevel: "low",
|
||||
actionType: "enable_feature",
|
||||
payload: {
|
||||
endpoint: "/proxy/network/api/s/default/set/setting/logging",
|
||||
method: "POST",
|
||||
body: { syslogEnabled: true },
|
||||
expectedState: { syslogEnabled: true }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const upnpEnabled = getBooleanSetting(settings, "raw.upnpEnabled", false);
|
||||
checks.push({
|
||||
controlId: "REMOTE-01",
|
||||
title: "Unsafe remote access patterns are disabled",
|
||||
description: "UPnP and opportunistic exposure should be disabled by default.",
|
||||
status: upnpEnabled ? "fail" : "pass",
|
||||
severity: "medium",
|
||||
evidence: [upnpEnabled ? "upnpEnabled=true" : "upnpEnabled=false"],
|
||||
recommendedActionIds: []
|
||||
});
|
||||
if (upnpEnabled) {
|
||||
recommendations.push(
|
||||
buildRecommendation({
|
||||
controlId: "REMOTE-01",
|
||||
title: "Disable UPnP",
|
||||
rationale: "UPnP can expose services unexpectedly.",
|
||||
riskLevel: "low",
|
||||
actionType: "disable_feature",
|
||||
payload: {
|
||||
endpoint: "/proxy/network/api/s/default/set/setting/upnp",
|
||||
method: "POST",
|
||||
body: { upnpEnabled: false },
|
||||
expectedState: { upnpEnabled: false }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const wanInbound = getStringSetting(settings, "raw.defaultWanInboundAction", "drop").toLowerCase();
|
||||
const wanInboundSafe = wanInbound === "drop" || wanInbound === "reject";
|
||||
checks.push({
|
||||
controlId: "FW-01",
|
||||
title: "WAN inbound default policy is restrictive",
|
||||
description: "Default WAN inbound traffic policy should deny unsolicited traffic.",
|
||||
status: wanInboundSafe ? "pass" : "fail",
|
||||
severity: "high",
|
||||
evidence: [`defaultWanInboundAction=${wanInbound}`],
|
||||
recommendedActionIds: []
|
||||
});
|
||||
if (!wanInboundSafe) {
|
||||
recommendations.push(
|
||||
buildRecommendation({
|
||||
controlId: "FW-01",
|
||||
title: "Set WAN inbound default policy to drop",
|
||||
rationale: "Restrictive defaults reduce unsolicited inbound exposure.",
|
||||
riskLevel: "low",
|
||||
actionType: "set_config",
|
||||
payload: {
|
||||
endpoint: "/proxy/network/api/s/default/set/setting/firewall",
|
||||
method: "POST",
|
||||
body: { defaultWanInboundAction: "drop" },
|
||||
expectedState: { defaultWanInboundAction: "drop" }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const sshEnabled = getBooleanSetting(settings, "raw.sshEnabled", false);
|
||||
checks.push({
|
||||
controlId: "SERV-01",
|
||||
title: "High-risk service exposure is controlled",
|
||||
description: "Management services should not be unnecessarily exposed.",
|
||||
status: sshEnabled ? "warn" : "pass",
|
||||
severity: "medium",
|
||||
evidence: [sshEnabled ? "sshEnabled=true" : "sshEnabled=false"],
|
||||
recommendedActionIds: []
|
||||
});
|
||||
if (sshEnabled) {
|
||||
recommendations.push(
|
||||
buildRecommendation({
|
||||
controlId: "SERV-01",
|
||||
title: "Review SSH service exposure",
|
||||
rationale: "SSH should be limited to trusted management hosts.",
|
||||
riskLevel: "low",
|
||||
actionType: "notify_only",
|
||||
payload: {
|
||||
endpoint: "",
|
||||
method: "POST",
|
||||
body: {},
|
||||
expectedState: { sshRestricted: true }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const failedControls = checks.filter((check) => check.status === "fail");
|
||||
const warningControls = checks.filter((check) => check.status === "warn");
|
||||
const passedControls = checks.filter((check) => check.status === "pass");
|
||||
const score = Math.max(0, Math.min(100, Math.round((passedControls.length / checks.length) * 100)));
|
||||
const driftDetected = previousSnapshot !== null && previousSnapshot.hash !== snapshot.hash;
|
||||
const driftSummary = driftDetected ? [`Snapshot hash changed from ${previousSnapshot.hash} to ${snapshot.hash}`] : [];
|
||||
|
||||
return {
|
||||
posture: {
|
||||
score,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
failedControls,
|
||||
warningControls,
|
||||
passedControls,
|
||||
driftDetected,
|
||||
driftSummary
|
||||
},
|
||||
recommendations
|
||||
};
|
||||
}
|
||||
}
|
||||
254
apps/api/src/services/remediationManager.ts
Normal file
254
apps/api/src/services/remediationManager.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { RemediationAction } from "@unfi/contracts";
|
||||
import type { AuditLedger } from "../lib/audit.js";
|
||||
import type { AlertService } from "./alertService.js";
|
||||
import type { DependencyService } from "./dependencyService.js";
|
||||
import type { Repository } from "./repository.js";
|
||||
import type { ActionResult, UnifiAdapter } from "./unifiAdapter.js";
|
||||
|
||||
const BURN_IN_STATE_KEY = "burn_in";
|
||||
|
||||
export class RemediationManager {
|
||||
constructor(
|
||||
private readonly repository: Repository,
|
||||
private readonly unifiAdapter: UnifiAdapter,
|
||||
private readonly dependencies: DependencyService,
|
||||
private readonly audit: AuditLedger,
|
||||
private readonly alerts: AlertService,
|
||||
private readonly burnInDays: number
|
||||
) {}
|
||||
|
||||
async enqueue(requestedBy: string, actions: RemediationAction[]): Promise<{
|
||||
enqueued: number;
|
||||
blocked: Array<{ actionId: string; reason: string }>;
|
||||
}> {
|
||||
await this.ensureBurnInInitialized();
|
||||
const allowed: RemediationAction[] = [];
|
||||
const blocked: Array<{ actionId: string; reason: string }> = [];
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.riskLevel !== "low") {
|
||||
blocked.push({ actionId: action.id, reason: "Only low-risk actions are eligible in MVP" });
|
||||
continue;
|
||||
}
|
||||
if (action.disruptive) {
|
||||
blocked.push({ actionId: action.id, reason: "Disruptive actions are blocked in MVP" });
|
||||
continue;
|
||||
}
|
||||
if (!action.reversible) {
|
||||
blocked.push({ actionId: action.id, reason: "Action must be reversible" });
|
||||
continue;
|
||||
}
|
||||
allowed.push(action);
|
||||
}
|
||||
|
||||
const items = await this.repository.enqueueActions(requestedBy, allowed);
|
||||
await this.audit.append({
|
||||
actor: requestedBy,
|
||||
actorRole: "operator",
|
||||
eventType: "remediation.queue.enqueued",
|
||||
entityType: "remediation_queue",
|
||||
entityId: items[0]?.id ?? "none",
|
||||
payload: {
|
||||
enqueued: items.length,
|
||||
blocked
|
||||
}
|
||||
});
|
||||
|
||||
return { enqueued: items.length, blocked };
|
||||
}
|
||||
|
||||
async applyQueue(requestedBy: string): Promise<{ scheduled: number; blockedReason: string | null }> {
|
||||
await this.ensureBurnInInitialized();
|
||||
const burnIn = await this.getBurnInStatus();
|
||||
if (!burnIn.complete) {
|
||||
return {
|
||||
scheduled: 0,
|
||||
blockedReason: `Burn-in not complete. Ready at ${burnIn.readyAt}`
|
||||
};
|
||||
}
|
||||
|
||||
const deps = await this.dependencies.check();
|
||||
if (!deps.modelGateReady || !deps.udm.controllerReachable) {
|
||||
return {
|
||||
scheduled: 0,
|
||||
blockedReason: "Dependency gate failed (UDM unreachable or GPT-5.3-Codex unavailable)"
|
||||
};
|
||||
}
|
||||
|
||||
const scheduled = await this.repository.markQueuedItemsForApply();
|
||||
await this.audit.append({
|
||||
actor: requestedBy,
|
||||
actorRole: "operator",
|
||||
eventType: "remediation.queue.apply_requested",
|
||||
entityType: "remediation_queue",
|
||||
entityId: "batch",
|
||||
payload: {
|
||||
scheduled
|
||||
}
|
||||
});
|
||||
return { scheduled, blockedReason: null };
|
||||
}
|
||||
|
||||
async processPendingQueue(): Promise<number> {
|
||||
const items = await this.repository.getPendingApplyItems(50);
|
||||
let processed = 0;
|
||||
|
||||
for (const item of items) {
|
||||
await this.processOne(item.id, item.requestedBy, item.action);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async processOne(queueItemId: string, requestedBy: string, action: RemediationAction): Promise<void> {
|
||||
await this.repository.setQueueItemStatus(queueItemId, "running");
|
||||
const execution = await this.repository.createExecution(queueItemId);
|
||||
await this.repository.setQueueItemStatus(queueItemId, "running", execution.id);
|
||||
|
||||
let backup: Awaited<ReturnType<UnifiAdapter["createBackupSnapshot"]>> | null = null;
|
||||
let rollbackPerformed = false;
|
||||
let verificationPassed = false;
|
||||
let status: "succeeded" | "failed" | "rolled_back" = "failed";
|
||||
const evidence: string[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
backup = await this.unifiAdapter.createBackupSnapshot();
|
||||
evidence.push(`backup:${backup.backupId}`);
|
||||
|
||||
const actionResult = await this.unifiAdapter.applyAction(action);
|
||||
evidence.push(...actionResult.evidence);
|
||||
if (!actionResult.success) {
|
||||
throw new Error(actionResult.message);
|
||||
}
|
||||
|
||||
verificationPassed = await this.verifyExpectedState(action);
|
||||
evidence.push(`verification:${verificationPassed ? "passed" : "failed"}`);
|
||||
|
||||
if (!verificationPassed) {
|
||||
rollbackPerformed = await this.tryRollback(action);
|
||||
status = rollbackPerformed ? "rolled_back" : "failed";
|
||||
throw new Error("Post-action verification failed");
|
||||
}
|
||||
|
||||
status = "succeeded";
|
||||
await this.audit.append({
|
||||
actor: requestedBy,
|
||||
actorRole: "operator",
|
||||
eventType: "remediation.execution.succeeded",
|
||||
entityType: "execution",
|
||||
entityId: execution.id,
|
||||
payload: {
|
||||
queueItemId,
|
||||
actionId: action.id,
|
||||
backupId: backup.backupId
|
||||
}
|
||||
});
|
||||
} catch (caught) {
|
||||
error = (caught as Error).message;
|
||||
if (!rollbackPerformed) {
|
||||
rollbackPerformed = await this.tryRollback(action);
|
||||
if (rollbackPerformed) {
|
||||
status = "rolled_back";
|
||||
}
|
||||
}
|
||||
await this.alerts.notify({
|
||||
severity: "critical",
|
||||
title: "Remediation execution failed",
|
||||
body: `Queue item ${queueItemId} failed: ${error}`,
|
||||
sendEmail: true
|
||||
});
|
||||
await this.audit.append({
|
||||
actor: requestedBy,
|
||||
actorRole: "operator",
|
||||
eventType: "remediation.execution.failed",
|
||||
entityType: "execution",
|
||||
entityId: execution.id,
|
||||
payload: {
|
||||
queueItemId,
|
||||
actionId: action.id,
|
||||
error,
|
||||
rollbackPerformed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.finishExecution({
|
||||
executionId: execution.id,
|
||||
status,
|
||||
backup,
|
||||
verificationPassed,
|
||||
rollbackPerformed,
|
||||
evidence,
|
||||
error
|
||||
});
|
||||
await this.repository.setQueueItemStatus(queueItemId, status);
|
||||
}
|
||||
|
||||
private async verifyExpectedState(action: RemediationAction): Promise<boolean> {
|
||||
const expectedState = action.expectedState;
|
||||
if (!expectedState || Object.keys(expectedState).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const snapshot = await this.unifiAdapter.getConfigSnapshot();
|
||||
const raw = (snapshot.settings.raw ?? snapshot.settings) as Record<string, unknown>;
|
||||
for (const [key, expected] of Object.entries(expectedState)) {
|
||||
if ((raw as Record<string, unknown>)[key] !== expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async tryRollback(action: RemediationAction): Promise<boolean> {
|
||||
const rollback = action.payload.rollback as
|
||||
| {
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!rollback?.endpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rollbackAction: RemediationAction = {
|
||||
...action,
|
||||
id: `${action.id}-rollback`,
|
||||
description: `Rollback for ${action.id}`,
|
||||
payload: {
|
||||
endpoint: rollback.endpoint,
|
||||
method: rollback.method ?? "POST",
|
||||
body: rollback.body ?? {}
|
||||
},
|
||||
expectedState: {}
|
||||
};
|
||||
|
||||
const result: ActionResult = await this.unifiAdapter.applyAction(rollbackAction);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
private async ensureBurnInInitialized(): Promise<void> {
|
||||
const state = await this.repository.getAppState(BURN_IN_STATE_KEY);
|
||||
if (!state) {
|
||||
await this.repository.upsertAppState(BURN_IN_STATE_KEY, {
|
||||
startedAt: new Date().toISOString(),
|
||||
days: this.burnInDays
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getBurnInStatus(): Promise<{ complete: boolean; readyAt: string }> {
|
||||
const state = await this.repository.getAppState(BURN_IN_STATE_KEY);
|
||||
const startedAt = new Date(String(state?.startedAt ?? new Date().toISOString()));
|
||||
const days = Number(state?.days ?? this.burnInDays);
|
||||
const readyAt = new Date(startedAt.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
return {
|
||||
complete: Date.now() >= readyAt.getTime(),
|
||||
readyAt: readyAt.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
738
apps/api/src/services/repository.ts
Normal file
738
apps/api/src/services/repository.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type {
|
||||
AuditEvent,
|
||||
BackupSnapshotResult,
|
||||
NormalizedEvent,
|
||||
Recommendation,
|
||||
RemediationAction,
|
||||
SecurityPosture,
|
||||
UdmConfigSnapshot
|
||||
} from "@unfi/contracts";
|
||||
import type { Database } from "../lib/db.js";
|
||||
import type { AppRole } from "../lib/auth.js";
|
||||
|
||||
export interface StoredUser {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: AppRole;
|
||||
totpSecretEncrypted: string;
|
||||
}
|
||||
|
||||
export interface UdmConnectionRecord {
|
||||
baseUrl: string;
|
||||
site: string;
|
||||
username: string;
|
||||
passwordEncrypted: string;
|
||||
verifyTls: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
requestedBy: string;
|
||||
action: RemediationAction;
|
||||
status: "queued" | "pending_apply" | "running" | "succeeded" | "failed" | "rolled_back" | "blocked";
|
||||
createdAt: string;
|
||||
applyRequestedAt: string | null;
|
||||
executionId: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionRecordEntity {
|
||||
id: string;
|
||||
queueItemId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
status: "queued" | "pending_apply" | "running" | "succeeded" | "failed" | "rolled_back" | "blocked";
|
||||
backup: BackupSnapshotResult | null;
|
||||
verificationPassed: boolean;
|
||||
rollbackPerformed: boolean;
|
||||
evidence: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class Repository {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async countUsers(): Promise<number> {
|
||||
const result = await this.db.query<{ count: string }>("SELECT COUNT(*)::text AS count FROM app_users");
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async insertUser(input: StoredUser): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO app_users (id, username, password_hash, role, totp_secret_encrypted)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
[input.id, input.username, input.passwordHash, input.role, input.totpSecretEncrypted]
|
||||
);
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<StoredUser | null> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: AppRole;
|
||||
totp_secret_encrypted: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, username, password_hash, role, totp_secret_encrypted
|
||||
FROM app_users
|
||||
WHERE username = $1
|
||||
`,
|
||||
[username]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
role: row.role,
|
||||
totpSecretEncrypted: row.totp_secret_encrypted
|
||||
};
|
||||
}
|
||||
|
||||
async saveUdmConnection(input: {
|
||||
baseUrl: string;
|
||||
site: string;
|
||||
username: string;
|
||||
passwordEncrypted: string;
|
||||
verifyTls: boolean;
|
||||
}): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO udm_connection (id, base_url, site, username, password_encrypted, verify_tls, updated_at)
|
||||
VALUES ('singleton', $1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
base_url = EXCLUDED.base_url,
|
||||
site = EXCLUDED.site,
|
||||
username = EXCLUDED.username,
|
||||
password_encrypted = EXCLUDED.password_encrypted,
|
||||
verify_tls = EXCLUDED.verify_tls,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[input.baseUrl, input.site, input.username, input.passwordEncrypted, input.verifyTls]
|
||||
);
|
||||
}
|
||||
|
||||
async getUdmConnection(): Promise<UdmConnectionRecord | null> {
|
||||
const result = await this.db.query<{
|
||||
base_url: string;
|
||||
site: string;
|
||||
username: string;
|
||||
password_encrypted: string;
|
||||
verify_tls: boolean;
|
||||
updated_at: string;
|
||||
}>(
|
||||
`
|
||||
SELECT base_url, site, username, password_encrypted, verify_tls, updated_at
|
||||
FROM udm_connection
|
||||
WHERE id = 'singleton'
|
||||
`
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: row.base_url,
|
||||
site: row.site,
|
||||
username: row.username,
|
||||
passwordEncrypted: row.password_encrypted,
|
||||
verifyTls: row.verify_tls,
|
||||
updatedAt: new Date(row.updated_at).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async upsertAppState(key: string, value: Record<string, unknown>): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO app_state (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[key, JSON.stringify(value)]
|
||||
);
|
||||
}
|
||||
|
||||
async getAppState(key: string): Promise<Record<string, unknown> | null> {
|
||||
const result = await this.db.query<{ value: Record<string, unknown> }>(
|
||||
`
|
||||
SELECT value
|
||||
FROM app_state
|
||||
WHERE key = $1
|
||||
`,
|
||||
[key]
|
||||
);
|
||||
|
||||
return result.rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
async insertEvents(events: NormalizedEvent[]): Promise<number> {
|
||||
if (events.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
for (const event of events) {
|
||||
const bucket = new Date(event.timestamp);
|
||||
bucket.setSeconds(0, 0);
|
||||
const result = await this.db.query(
|
||||
`
|
||||
INSERT INTO normalized_events (
|
||||
id, timestamp, time_bucket, source, severity, category, message, fingerprint, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
ON CONFLICT (fingerprint, time_bucket) DO NOTHING
|
||||
`,
|
||||
[
|
||||
event.id,
|
||||
event.timestamp,
|
||||
bucket.toISOString(),
|
||||
event.source,
|
||||
event.severity,
|
||||
event.category,
|
||||
event.message,
|
||||
event.fingerprint,
|
||||
JSON.stringify(event.metadata)
|
||||
]
|
||||
);
|
||||
inserted += result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async getRecentEvents(limit = 200): Promise<NormalizedEvent[]> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: "unifi_api" | "syslog";
|
||||
severity: "debug" | "info" | "warn" | "error" | "critical";
|
||||
category: string;
|
||||
message: string;
|
||||
fingerprint: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
SELECT id, timestamp, source, severity, category, message, fingerprint, metadata
|
||||
FROM normalized_events
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
timestamp: new Date(row.timestamp).toISOString(),
|
||||
source: row.source,
|
||||
severity: row.severity,
|
||||
category: row.category,
|
||||
message: row.message,
|
||||
fingerprint: row.fingerprint,
|
||||
metadata: row.metadata ?? {}
|
||||
}));
|
||||
}
|
||||
|
||||
async insertConfigSnapshot(snapshot: UdmConfigSnapshot): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO config_snapshots (
|
||||
id, captured_at, firmware_version, firmware_channel, site, settings, hash
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
snapshot.id,
|
||||
snapshot.capturedAt,
|
||||
snapshot.firmwareVersion,
|
||||
snapshot.firmwareChannel,
|
||||
snapshot.site,
|
||||
JSON.stringify(snapshot.settings),
|
||||
snapshot.hash
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getLatestConfigSnapshot(): Promise<UdmConfigSnapshot | null> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
captured_at: string;
|
||||
firmware_version: string;
|
||||
firmware_channel: "stable" | "early_access" | "unknown";
|
||||
site: string;
|
||||
settings: Record<string, unknown>;
|
||||
hash: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, captured_at, firmware_version, firmware_channel, site, settings, hash
|
||||
FROM config_snapshots
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
capturedAt: new Date(row.captured_at).toISOString(),
|
||||
firmwareVersion: row.firmware_version,
|
||||
firmwareChannel: row.firmware_channel,
|
||||
site: row.site,
|
||||
settings: row.settings,
|
||||
hash: row.hash
|
||||
};
|
||||
}
|
||||
|
||||
async getPreviousConfigSnapshot(excludingId: string): Promise<UdmConfigSnapshot | null> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
captured_at: string;
|
||||
firmware_version: string;
|
||||
firmware_channel: "stable" | "early_access" | "unknown";
|
||||
site: string;
|
||||
settings: Record<string, unknown>;
|
||||
hash: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, captured_at, firmware_version, firmware_channel, site, settings, hash
|
||||
FROM config_snapshots
|
||||
WHERE id <> $1
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[excludingId]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
capturedAt: new Date(row.captured_at).toISOString(),
|
||||
firmwareVersion: row.firmware_version,
|
||||
firmwareChannel: row.firmware_channel,
|
||||
site: row.site,
|
||||
settings: row.settings,
|
||||
hash: row.hash
|
||||
};
|
||||
}
|
||||
|
||||
async insertPostureRun(snapshotId: string, posture: SecurityPosture): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO posture_runs (evaluated_at, snapshot_id, score, posture)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
`,
|
||||
[posture.evaluatedAt, snapshotId, posture.score, JSON.stringify(posture)]
|
||||
);
|
||||
}
|
||||
|
||||
async getLatestPosture(): Promise<SecurityPosture | null> {
|
||||
const result = await this.db.query<{ posture: SecurityPosture }>(
|
||||
`
|
||||
SELECT posture
|
||||
FROM posture_runs
|
||||
ORDER BY evaluated_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
);
|
||||
return result.rows[0]?.posture ?? null;
|
||||
}
|
||||
|
||||
async replaceRecommendations(model: string, recommendations: Recommendation[]): Promise<void> {
|
||||
await this.db.query("DELETE FROM recommendations");
|
||||
for (const recommendation of recommendations) {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO recommendations (id, generated_at, model, recommendation)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
`,
|
||||
[recommendation.id, recommendation.createdAt, model, JSON.stringify(recommendation)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getRecommendations(): Promise<{ model: string; recommendations: Recommendation[]; generatedAt: string | null }> {
|
||||
const result = await this.db.query<{
|
||||
model: string;
|
||||
recommendation: Recommendation;
|
||||
generated_at: string;
|
||||
}>(
|
||||
`
|
||||
SELECT model, recommendation, generated_at
|
||||
FROM recommendations
|
||||
ORDER BY generated_at DESC
|
||||
`
|
||||
);
|
||||
|
||||
return {
|
||||
model: result.rows[0]?.model ?? "rule-engine",
|
||||
generatedAt: result.rows[0] ? new Date(result.rows[0].generated_at).toISOString() : null,
|
||||
recommendations: result.rows.map((row) => row.recommendation)
|
||||
};
|
||||
}
|
||||
|
||||
async enqueueActions(requestedBy: string, actions: RemediationAction[]): Promise<QueueItem[]> {
|
||||
const inserted: QueueItem[] = [];
|
||||
for (const action of actions) {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
requested_by: string;
|
||||
action: RemediationAction;
|
||||
status: QueueItem["status"];
|
||||
created_at: string;
|
||||
}>(
|
||||
`
|
||||
INSERT INTO remediation_queue (requested_by, action, status)
|
||||
VALUES ($1, $2::jsonb, 'queued')
|
||||
RETURNING id, requested_by, action, status, created_at
|
||||
`,
|
||||
[requestedBy, JSON.stringify(action)]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inserted.push({
|
||||
id: row.id,
|
||||
requestedBy: row.requested_by,
|
||||
action: row.action,
|
||||
status: row.status,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
applyRequestedAt: null,
|
||||
executionId: null
|
||||
});
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async markQueuedItemsForApply(): Promise<number> {
|
||||
const result = await this.db.query(
|
||||
`
|
||||
UPDATE remediation_queue
|
||||
SET status = 'pending_apply', apply_requested_at = NOW()
|
||||
WHERE status = 'queued'
|
||||
`
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
async getPendingApplyItems(limit = 50): Promise<QueueItem[]> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
requested_by: string;
|
||||
action: RemediationAction;
|
||||
status: QueueItem["status"];
|
||||
created_at: string;
|
||||
apply_requested_at: string | null;
|
||||
execution_id: string | null;
|
||||
}>(
|
||||
`
|
||||
SELECT id, requested_by, action, status, created_at, apply_requested_at, execution_id
|
||||
FROM remediation_queue
|
||||
WHERE status IN ('pending_apply', 'running')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
requestedBy: row.requested_by,
|
||||
action: row.action,
|
||||
status: row.status,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
applyRequestedAt: row.apply_requested_at ? new Date(row.apply_requested_at).toISOString() : null,
|
||||
executionId: row.execution_id
|
||||
}));
|
||||
}
|
||||
|
||||
async setQueueItemStatus(
|
||||
queueItemId: string,
|
||||
status: QueueItem["status"],
|
||||
executionId: string | null = null
|
||||
): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
UPDATE remediation_queue
|
||||
SET status = $2, execution_id = COALESCE($3, execution_id)
|
||||
WHERE id = $1
|
||||
`,
|
||||
[queueItemId, status, executionId]
|
||||
);
|
||||
}
|
||||
|
||||
async createExecution(queueItemId: string): Promise<ExecutionRecordEntity> {
|
||||
const now = new Date().toISOString();
|
||||
const executionId = uuid();
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
queue_item_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
status: ExecutionRecordEntity["status"];
|
||||
backup: BackupSnapshotResult | null;
|
||||
verification_passed: boolean;
|
||||
rollback_performed: boolean;
|
||||
evidence: string[];
|
||||
error: string | null;
|
||||
}>(
|
||||
`
|
||||
INSERT INTO execution_records (
|
||||
id, queue_item_id, started_at, status, evidence
|
||||
)
|
||||
VALUES ($1, $2, $3, 'running', '[]'::jsonb)
|
||||
RETURNING id, queue_item_id, started_at, finished_at, status, backup, verification_passed, rollback_performed, evidence, error
|
||||
`,
|
||||
[executionId, queueItemId, now]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error("Failed to create execution record");
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
queueItemId: row.queue_item_id,
|
||||
startedAt: new Date(row.started_at).toISOString(),
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : null,
|
||||
status: row.status,
|
||||
backup: row.backup,
|
||||
verificationPassed: row.verification_passed,
|
||||
rollbackPerformed: row.rollback_performed,
|
||||
evidence: row.evidence ?? [],
|
||||
error: row.error
|
||||
};
|
||||
}
|
||||
|
||||
async finishExecution(input: {
|
||||
executionId: string;
|
||||
status: ExecutionRecordEntity["status"];
|
||||
backup: BackupSnapshotResult | null;
|
||||
verificationPassed: boolean;
|
||||
rollbackPerformed: boolean;
|
||||
evidence: string[];
|
||||
error: string | null;
|
||||
}): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
UPDATE execution_records
|
||||
SET
|
||||
finished_at = NOW(),
|
||||
status = $2,
|
||||
backup = $3::jsonb,
|
||||
verification_passed = $4,
|
||||
rollback_performed = $5,
|
||||
evidence = $6::jsonb,
|
||||
error = $7
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
input.executionId,
|
||||
input.status,
|
||||
input.backup ? JSON.stringify(input.backup) : null,
|
||||
input.verificationPassed,
|
||||
input.rollbackPerformed,
|
||||
JSON.stringify(input.evidence),
|
||||
input.error
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getExecutionById(executionId: string): Promise<ExecutionRecordEntity | null> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
queue_item_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
status: ExecutionRecordEntity["status"];
|
||||
backup: BackupSnapshotResult | null;
|
||||
verification_passed: boolean;
|
||||
rollback_performed: boolean;
|
||||
evidence: string[];
|
||||
error: string | null;
|
||||
}>(
|
||||
`
|
||||
SELECT id, queue_item_id, started_at, finished_at, status, backup, verification_passed, rollback_performed, evidence, error
|
||||
FROM execution_records
|
||||
WHERE id = $1
|
||||
`,
|
||||
[executionId]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
queueItemId: row.queue_item_id,
|
||||
startedAt: new Date(row.started_at).toISOString(),
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : null,
|
||||
status: row.status,
|
||||
backup: row.backup,
|
||||
verificationPassed: row.verification_passed,
|
||||
rollbackPerformed: row.rollback_performed,
|
||||
evidence: row.evidence ?? [],
|
||||
error: row.error
|
||||
};
|
||||
}
|
||||
|
||||
async insertAuditEvent(event: AuditEvent): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO audit_events (
|
||||
id, occurred_at, actor, actor_role, event_type, entity_type, entity_id, payload, prev_hash, hash
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)
|
||||
`,
|
||||
[
|
||||
event.id,
|
||||
event.occurredAt,
|
||||
event.actor,
|
||||
event.actorRole,
|
||||
event.eventType,
|
||||
event.entityType,
|
||||
event.entityId,
|
||||
JSON.stringify(event.payload),
|
||||
event.prevHash,
|
||||
event.hash
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getLastAuditEvent(): Promise<AuditEvent | null> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
actor: string;
|
||||
actor_role: AuditEvent["actorRole"];
|
||||
event_type: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
payload: Record<string, unknown>;
|
||||
prev_hash: string | null;
|
||||
hash: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, occurred_at, actor, actor_role, event_type, entity_type, entity_id, payload, prev_hash, hash
|
||||
FROM audit_events
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
occurredAt: new Date(row.occurred_at).toISOString(),
|
||||
actor: row.actor,
|
||||
actorRole: row.actor_role,
|
||||
eventType: row.event_type,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
payload: row.payload,
|
||||
prevHash: row.prev_hash,
|
||||
hash: row.hash
|
||||
};
|
||||
}
|
||||
|
||||
async listAuditEvents(limit = 200): Promise<AuditEvent[]> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
actor: string;
|
||||
actor_role: AuditEvent["actorRole"];
|
||||
event_type: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
payload: Record<string, unknown>;
|
||||
prev_hash: string | null;
|
||||
hash: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, occurred_at, actor, actor_role, event_type, entity_type, entity_id, payload, prev_hash, hash
|
||||
FROM audit_events
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
occurredAt: new Date(row.occurred_at).toISOString(),
|
||||
actor: row.actor,
|
||||
actorRole: row.actor_role,
|
||||
eventType: row.event_type,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
payload: row.payload,
|
||||
prevHash: row.prev_hash,
|
||||
hash: row.hash
|
||||
}));
|
||||
}
|
||||
|
||||
async addNotification(input: { severity: string; title: string; body: string }): Promise<void> {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO notifications (severity, title, body)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
[input.severity, input.title, input.body]
|
||||
);
|
||||
}
|
||||
|
||||
async listNotifications(limit = 100): Promise<Array<{ id: string; severity: string; title: string; body: string; createdAt: string }>> {
|
||||
const result = await this.db.query<{
|
||||
id: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, severity, title, body, created_at
|
||||
FROM notifications
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
severity: row.severity,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
createdAt: new Date(row.created_at).toISOString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
56
apps/api/src/services/securityPipeline.ts
Normal file
56
apps/api/src/services/securityPipeline.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { recommendationSetSchema, securityAnalysisInputSchema, type RecommendationSet, type SecurityPosture } from "@unfi/contracts";
|
||||
import type { AuditLedger } from "../lib/audit.js";
|
||||
import type { Repository } from "./repository.js";
|
||||
import type { CodexClient } from "./llmOrchestrator.js";
|
||||
import type { PolicyEngine } from "./policyEngine.js";
|
||||
import type { UnifiAdapter } from "./unifiAdapter.js";
|
||||
|
||||
export class SecurityPipelineService {
|
||||
constructor(
|
||||
private readonly repository: Repository,
|
||||
private readonly unifiAdapter: UnifiAdapter,
|
||||
private readonly policyEngine: PolicyEngine,
|
||||
private readonly codexClient: CodexClient,
|
||||
private readonly audit: AuditLedger
|
||||
) {}
|
||||
|
||||
async run(): Promise<{ posture: SecurityPosture; recommendations: RecommendationSet }> {
|
||||
const snapshot = await this.unifiAdapter.getConfigSnapshot();
|
||||
await this.repository.insertConfigSnapshot(snapshot);
|
||||
|
||||
const previousSnapshot = await this.repository.getPreviousConfigSnapshot(snapshot.id);
|
||||
const evaluation = this.policyEngine.evaluate(snapshot, previousSnapshot);
|
||||
await this.repository.insertPostureRun(snapshot.id, evaluation.posture);
|
||||
|
||||
const recentEvents = await this.repository.getRecentEvents(500);
|
||||
const analysisInput = securityAnalysisInputSchema.parse({
|
||||
posture: evaluation.posture,
|
||||
recentEvents,
|
||||
snapshot,
|
||||
policyVersion: "mvp-1"
|
||||
});
|
||||
|
||||
const recommendations = recommendationSetSchema.parse(
|
||||
await this.codexClient.recommend(analysisInput, evaluation.recommendations)
|
||||
);
|
||||
await this.repository.replaceRecommendations(recommendations.model, recommendations.recommendations);
|
||||
|
||||
await this.audit.append({
|
||||
actor: "system",
|
||||
actorRole: "system",
|
||||
eventType: "security.analysis.completed",
|
||||
entityType: "snapshot",
|
||||
entityId: snapshot.id,
|
||||
payload: {
|
||||
postureScore: evaluation.posture.score,
|
||||
recommendationCount: recommendations.recommendations.length,
|
||||
model: recommendations.model
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
posture: evaluation.posture,
|
||||
recommendations
|
||||
};
|
||||
}
|
||||
}
|
||||
337
apps/api/src/services/unifiAdapter.ts
Normal file
337
apps/api/src/services/unifiAdapter.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { Agent, request } from "undici";
|
||||
import type { BackupSnapshotResult, CapabilityReport, NormalizedEvent, RemediationAction, UdmConfigSnapshot } from "@unfi/contracts";
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
export interface UdmConnection {
|
||||
baseUrl: string;
|
||||
site: string;
|
||||
username: string;
|
||||
password: string;
|
||||
verifyTls: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiAdapter {
|
||||
probeCapabilities(): Promise<CapabilityReport>;
|
||||
streamEvents(sinceCursor?: string): AsyncIterable<NormalizedEvent>;
|
||||
getConfigSnapshot(): Promise<UdmConfigSnapshot>;
|
||||
createBackupSnapshot(): Promise<BackupSnapshotResult>;
|
||||
applyAction(action: RemediationAction): Promise<ActionResult>;
|
||||
}
|
||||
|
||||
export class UnifiHttpAdapter implements UnifiAdapter {
|
||||
private sessionCookie: string | null = null;
|
||||
private sessionCreatedAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly getConnection: () => Promise<UdmConnection>,
|
||||
private readonly firmwarePolicy: "stable_only" | "allow_early_access"
|
||||
) {}
|
||||
|
||||
async probeCapabilities(): Promise<CapabilityReport> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const issues: string[] = [];
|
||||
let firmwareVersion = "unknown";
|
||||
let firmwareChannel: "stable" | "early_access" | "unknown" = "unknown";
|
||||
let controllerReachable = false;
|
||||
const connection = await this.getConnection();
|
||||
|
||||
try {
|
||||
await this.ensureSession();
|
||||
controllerReachable = true;
|
||||
const status = await this.fetchJson("/status");
|
||||
const meta = (status as Record<string, unknown>) ?? {};
|
||||
if (typeof meta.version === "string") {
|
||||
firmwareVersion = meta.version;
|
||||
}
|
||||
if (typeof meta.channel === "string") {
|
||||
firmwareChannel = meta.channel.includes("early") ? "early_access" : "stable";
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push(`Controller check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
let eventsApi = false;
|
||||
let configRead = false;
|
||||
try {
|
||||
await this.fetchJson(this.networkPath(connection.site, "/stat/event?limit=1"));
|
||||
eventsApi = true;
|
||||
} catch {
|
||||
issues.push("UniFi event API unavailable");
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fetchJson(this.networkPath(connection.site, "/get/setting"));
|
||||
configRead = true;
|
||||
} catch {
|
||||
issues.push("UniFi config read API unavailable");
|
||||
}
|
||||
|
||||
const backupCreate = configRead;
|
||||
const actionApply = configRead;
|
||||
|
||||
if (this.firmwarePolicy === "stable_only" && firmwareChannel === "early_access") {
|
||||
issues.push("Early access firmware channel is not supported by policy");
|
||||
}
|
||||
|
||||
return {
|
||||
checkedAt,
|
||||
controllerReachable,
|
||||
firmwareVersion,
|
||||
firmwareChannel,
|
||||
modelGateReady: false,
|
||||
features: {
|
||||
eventsApi,
|
||||
configRead,
|
||||
backupCreate,
|
||||
actionApply,
|
||||
syslogFallback: true
|
||||
},
|
||||
issues
|
||||
};
|
||||
}
|
||||
|
||||
async *streamEvents(sinceCursor?: string): AsyncIterable<NormalizedEvent> {
|
||||
let cursor = sinceCursor;
|
||||
while (true) {
|
||||
const events = await this.fetchRecentEvents(cursor);
|
||||
for (const event of events) {
|
||||
cursor = event.timestamp;
|
||||
yield event;
|
||||
}
|
||||
await sleep(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRecentEvents(sinceCursor?: string): Promise<NormalizedEvent[]> {
|
||||
try {
|
||||
const connection = await this.getConnection();
|
||||
const query = sinceCursor ? `?start=${encodeURIComponent(sinceCursor)}` : "?limit=100";
|
||||
const payload = await this.fetchJson(this.networkPath(connection.site, `/stat/event${query}`));
|
||||
const rows = this.extractDataRows(payload);
|
||||
return rows.map((row) => this.normalizeApiEvent(row));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getConfigSnapshot(): Promise<UdmConfigSnapshot> {
|
||||
const connection = await this.getConnection();
|
||||
const data = await this.fetchJson(this.networkPath(connection.site, "/get/setting"));
|
||||
const now = new Date().toISOString();
|
||||
const snapshotSettings = {
|
||||
raw: data
|
||||
} as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(snapshotSettings);
|
||||
return {
|
||||
id: uuid(),
|
||||
capturedAt: now,
|
||||
firmwareVersion: "unknown",
|
||||
firmwareChannel: "unknown",
|
||||
site: connection.site,
|
||||
settings: snapshotSettings,
|
||||
hash: createHash("sha256").update(serialized).digest("hex")
|
||||
};
|
||||
}
|
||||
|
||||
async createBackupSnapshot(): Promise<BackupSnapshotResult> {
|
||||
const snapshot = await this.getConfigSnapshot();
|
||||
return {
|
||||
backupId: snapshot.id,
|
||||
createdAt: snapshot.capturedAt,
|
||||
location: `snapshot:${snapshot.id}`
|
||||
};
|
||||
}
|
||||
|
||||
async applyAction(action: RemediationAction): Promise<ActionResult> {
|
||||
if (action.riskLevel !== "low") {
|
||||
return {
|
||||
success: false,
|
||||
message: "Only low-risk actions are supported",
|
||||
evidence: ["risk_guard:blocked"]
|
||||
};
|
||||
}
|
||||
if (action.disruptive) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Disruptive actions are blocked in MVP",
|
||||
evidence: ["disruptive_guard:blocked"]
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === "notify_only") {
|
||||
return {
|
||||
success: true,
|
||||
message: "No-op action acknowledged",
|
||||
evidence: ["notify_only"]
|
||||
};
|
||||
}
|
||||
|
||||
const endpoint = String(action.payload.endpoint ?? "");
|
||||
const method = String(action.payload.method ?? "POST").toUpperCase();
|
||||
const body = (action.payload.body as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
if (!endpoint.startsWith("/proxy/network/")) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Action endpoint is outside allowed UniFi network paths",
|
||||
evidence: ["path_guard:blocked"]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fetchJson(endpoint, {
|
||||
method,
|
||||
body: method === "GET" ? undefined : JSON.stringify(body),
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Action applied",
|
||||
evidence: [`endpoint:${endpoint}`, `method:${method}`]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to apply action: ${(error as Error).message}`,
|
||||
evidence: [`endpoint:${endpoint}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private networkPath(site: string, pathname: string): string {
|
||||
return `/proxy/network/api/s/${site}${pathname}`;
|
||||
}
|
||||
|
||||
private extractDataRows(payload: unknown): Record<string, unknown>[] {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return [];
|
||||
}
|
||||
const data = (payload as { data?: unknown }).data;
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
return data.filter((row): row is Record<string, unknown> => typeof row === "object" && row !== null);
|
||||
}
|
||||
|
||||
private normalizeApiEvent(raw: Record<string, unknown>): NormalizedEvent {
|
||||
const message = String(raw.msg ?? raw.message ?? "unifi-event");
|
||||
const timestamp =
|
||||
typeof raw.time === "number"
|
||||
? new Date(raw.time * 1000).toISOString()
|
||||
: typeof raw.timestamp === "number"
|
||||
? new Date(raw.timestamp).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(`${message}|${String(raw.key ?? "")}|${timestamp.slice(0, 16)}`)
|
||||
.digest("hex");
|
||||
|
||||
const severity = this.mapSeverity(String(raw.severity ?? raw.level ?? "info"));
|
||||
return {
|
||||
id: String(raw._id ?? uuid()),
|
||||
timestamp,
|
||||
source: "unifi_api",
|
||||
severity,
|
||||
category: String(raw.key ?? raw.category ?? "network"),
|
||||
message,
|
||||
fingerprint,
|
||||
metadata: raw
|
||||
};
|
||||
}
|
||||
|
||||
private mapSeverity(value: string): NormalizedEvent["severity"] {
|
||||
const normalized = value.toLowerCase();
|
||||
if (["debug"].includes(normalized)) {
|
||||
return "debug";
|
||||
}
|
||||
if (["notice", "info", "informational"].includes(normalized)) {
|
||||
return "info";
|
||||
}
|
||||
if (["warning", "warn"].includes(normalized)) {
|
||||
return "warn";
|
||||
}
|
||||
if (["err", "error"].includes(normalized)) {
|
||||
return "error";
|
||||
}
|
||||
if (["crit", "critical", "alert", "emerg"].includes(normalized)) {
|
||||
return "critical";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
private async ensureSession(): Promise<void> {
|
||||
const ttlMs = 15 * 60 * 1000;
|
||||
if (this.sessionCookie && Date.now() - this.sessionCreatedAt < ttlMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.getConnection();
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: connection.verifyTls
|
||||
}
|
||||
});
|
||||
const response = await request(`${connection.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: connection.username,
|
||||
password: connection.password,
|
||||
remember: true
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
dispatcher: agent
|
||||
});
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
throw new Error(`UDM login failed with HTTP ${response.statusCode}`);
|
||||
}
|
||||
const cookies = response.headers["set-cookie"];
|
||||
if (!cookies) {
|
||||
throw new Error("UDM login did not return session cookie");
|
||||
}
|
||||
const cookieValue = Array.isArray(cookies) ? cookies.map((c) => c.split(";")[0] ?? "").join("; ") : (cookies.split(";")[0] ?? "");
|
||||
if (!cookieValue) {
|
||||
throw new Error("UDM login session cookie was empty");
|
||||
}
|
||||
this.sessionCookie = cookieValue;
|
||||
this.sessionCreatedAt = Date.now();
|
||||
}
|
||||
|
||||
private async fetchJson(pathname: string, init?: RequestInit): Promise<unknown> {
|
||||
await this.ensureSession();
|
||||
const connection = await this.getConnection();
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: connection.verifyTls
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${connection.baseUrl}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
cookie: this.sessionCookie ?? "",
|
||||
accept: "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
dispatcher: agent as unknown as never
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`UniFi request failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
133
apps/api/src/sql/001_init.sql
Normal file
133
apps/api/src/sql/001_init.sql
Normal file
@@ -0,0 +1,133 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS udm_connection (
|
||||
id TEXT PRIMARY KEY,
|
||||
base_url TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password_encrypted TEXT NOT NULL,
|
||||
verify_tls BOOLEAN NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'operator', 'viewer')),
|
||||
totp_secret_encrypted TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS normalized_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
time_bucket TIMESTAMPTZ NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS normalized_events_dedupe_idx
|
||||
ON normalized_events (fingerprint, time_bucket);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS normalized_events_timestamp_idx
|
||||
ON normalized_events (timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
captured_at TIMESTAMPTZ NOT NULL,
|
||||
firmware_version TEXT NOT NULL,
|
||||
firmware_channel TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
settings JSONB NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS config_snapshots_captured_at_idx
|
||||
ON config_snapshots (captured_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posture_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||
snapshot_id TEXT NOT NULL REFERENCES config_snapshots(id),
|
||||
score INTEGER NOT NULL,
|
||||
posture JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS posture_runs_evaluated_at_idx
|
||||
ON posture_runs (evaluated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendations (
|
||||
id TEXT PRIMARY KEY,
|
||||
generated_at TIMESTAMPTZ NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
recommendation JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS recommendations_generated_at_idx
|
||||
ON recommendations (generated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remediation_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
requested_by TEXT NOT NULL,
|
||||
action JSONB NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'pending_apply', 'running', 'succeeded', 'failed', 'rolled_back', 'blocked')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
apply_requested_at TIMESTAMPTZ NULL,
|
||||
execution_id UUID NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS remediation_queue_status_idx
|
||||
ON remediation_queue (status, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS execution_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
queue_item_id UUID NOT NULL REFERENCES remediation_queue(id),
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'pending_apply', 'running', 'succeeded', 'failed', 'rolled_back', 'blocked')),
|
||||
backup JSONB NULL,
|
||||
verification_passed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
rollback_performed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
evidence JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS execution_records_started_at_idx
|
||||
ON execution_records (started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_role TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
prev_hash TEXT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS audit_events_occurred_at_idx
|
||||
ON audit_events (occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
read_at TIMESTAMPTZ NULL
|
||||
);
|
||||
11
apps/api/src/types/fastify.d.ts
vendored
Normal file
11
apps/api/src/types/fastify.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import "fastify";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: "owner" | "operator" | "viewer";
|
||||
};
|
||||
}
|
||||
}
|
||||
62
apps/api/test/policyEngine.test.ts
Normal file
62
apps/api/test/policyEngine.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PolicyEngine } from "../src/services/policyEngine.js";
|
||||
|
||||
describe("PolicyEngine", () => {
|
||||
it("flags insecure defaults and emits low-risk recommendations", () => {
|
||||
const engine = new PolicyEngine();
|
||||
const snapshot = {
|
||||
id: "snap-1",
|
||||
capturedAt: new Date().toISOString(),
|
||||
firmwareVersion: "9.0.0",
|
||||
firmwareChannel: "stable" as const,
|
||||
site: "default",
|
||||
settings: {
|
||||
raw: {
|
||||
remoteAccess: true,
|
||||
adminMfaRequired: false,
|
||||
syslogEnabled: false,
|
||||
upnpEnabled: true,
|
||||
defaultWanInboundAction: "accept",
|
||||
sshEnabled: true
|
||||
}
|
||||
},
|
||||
hash: "abc123"
|
||||
};
|
||||
|
||||
const result = engine.evaluate(snapshot, null);
|
||||
|
||||
expect(result.posture.failedControls.map((control) => control.controlId)).toContain("MGMT-01");
|
||||
expect(result.posture.failedControls.map((control) => control.controlId)).toContain("FW-01");
|
||||
expect(result.recommendations.every((recommendation) => recommendation.actions.every((action) => action.riskLevel === "low"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
result.recommendations.every((recommendation) =>
|
||||
recommendation.actions.every((action) => action.disruptive === false && action.reversible === true)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects drift when snapshot hash changes", () => {
|
||||
const engine = new PolicyEngine();
|
||||
const now = new Date().toISOString();
|
||||
const previous = {
|
||||
id: "prev",
|
||||
capturedAt: now,
|
||||
firmwareVersion: "9.0.0",
|
||||
firmwareChannel: "stable" as const,
|
||||
site: "default",
|
||||
settings: { raw: {} },
|
||||
hash: "one"
|
||||
};
|
||||
const current = {
|
||||
...previous,
|
||||
id: "curr",
|
||||
hash: "two"
|
||||
};
|
||||
|
||||
const result = engine.evaluate(current, previous);
|
||||
expect(result.posture.driftDetected).toBe(true);
|
||||
expect(result.posture.driftSummary.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
81
apps/api/test/remediationManager.test.ts
Normal file
81
apps/api/test/remediationManager.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RemediationAction } from "@unfi/contracts";
|
||||
import { RemediationManager } from "../src/services/remediationManager.js";
|
||||
|
||||
describe("RemediationManager", () => {
|
||||
it("enqueues only low-risk non-disruptive reversible actions", async () => {
|
||||
const enqueueActions = vi.fn().mockResolvedValue([{ id: "q1" }]);
|
||||
const repository = {
|
||||
getAppState: vi.fn().mockResolvedValue(null),
|
||||
upsertAppState: vi.fn().mockResolvedValue(undefined),
|
||||
enqueueActions,
|
||||
markQueuedItemsForApply: vi.fn(),
|
||||
getPendingApplyItems: vi.fn(),
|
||||
setQueueItemStatus: vi.fn(),
|
||||
createExecution: vi.fn(),
|
||||
finishExecution: vi.fn()
|
||||
};
|
||||
const manager = new RemediationManager(
|
||||
repository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ append: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
{ notify: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
7
|
||||
);
|
||||
|
||||
const safeAction: RemediationAction = {
|
||||
id: "safe",
|
||||
controlId: "MGMT-01",
|
||||
type: "set_config",
|
||||
description: "safe",
|
||||
riskLevel: "low",
|
||||
disruptive: false,
|
||||
reversible: true,
|
||||
payload: {},
|
||||
expectedState: {}
|
||||
};
|
||||
const unsafeAction: RemediationAction = {
|
||||
...safeAction,
|
||||
id: "unsafe",
|
||||
riskLevel: "high",
|
||||
disruptive: true
|
||||
};
|
||||
|
||||
const result = await manager.enqueue("owner", [safeAction, unsafeAction]);
|
||||
expect(result.enqueued).toBe(1);
|
||||
expect(result.blocked.length).toBe(1);
|
||||
expect(enqueueActions).toHaveBeenCalledWith("owner", [safeAction]);
|
||||
});
|
||||
|
||||
it("blocks queue apply during burn-in", async () => {
|
||||
const repository = {
|
||||
getAppState: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
startedAt: new Date().toISOString(),
|
||||
days: 7
|
||||
}),
|
||||
upsertAppState: vi.fn().mockResolvedValue(undefined),
|
||||
markQueuedItemsForApply: vi.fn().mockResolvedValue(2),
|
||||
getPendingApplyItems: vi.fn(),
|
||||
setQueueItemStatus: vi.fn(),
|
||||
createExecution: vi.fn(),
|
||||
finishExecution: vi.fn(),
|
||||
enqueueActions: vi.fn()
|
||||
};
|
||||
const manager = new RemediationManager(
|
||||
repository as never,
|
||||
{} as never,
|
||||
{ check: vi.fn().mockResolvedValue({ modelGateReady: true, udm: { controllerReachable: true } }) } as never,
|
||||
{ append: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
{ notify: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
7
|
||||
);
|
||||
|
||||
const result = await manager.applyQueue("owner");
|
||||
expect(result.scheduled).toBe(0);
|
||||
expect(result.blockedReason).toMatch(/Burn-in not complete/);
|
||||
});
|
||||
});
|
||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
15
apps/web/Dockerfile
Normal file
15
apps/web/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /workspace
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/tsconfig.json ./packages/contracts/
|
||||
COPY packages/contracts/src ./packages/contracts/src
|
||||
COPY apps/web/package.json apps/web/tsconfig.json apps/web/tsconfig.node.json apps/web/vite.config.ts ./apps/web/
|
||||
COPY apps/web/index.html ./apps/web/index.html
|
||||
COPY apps/web/src ./apps/web/src
|
||||
RUN npm install
|
||||
RUN npm run -w @unfi/contracts build
|
||||
RUN npm run -w @unfi/web build
|
||||
|
||||
FROM nginx:1.28-alpine
|
||||
COPY --from=build /workspace/apps/web/dist /usr/share/nginx/html
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UNFI Security Copilot</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
apps/web/nginx.conf
Normal file
19
apps/web/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
29
apps/web/package.json
Normal file
29
apps/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@unfi/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo \"No linter configured for web\"",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@unfi/contracts": "0.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
139
apps/web/src/App.tsx
Normal file
139
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { api, clearToken, getToken, setToken } from "./lib/api";
|
||||
import { AuditPage } from "./pages/AuditPage";
|
||||
import { LogsPage } from "./pages/LogsPage";
|
||||
import { PosturePage } from "./pages/PosturePage";
|
||||
import { QueuePage } from "./pages/QueuePage";
|
||||
import { RecommendationsPage } from "./pages/RecommendationsPage";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/logs", label: "Realtime Logs" },
|
||||
{ to: "/posture", label: "Security Posture" },
|
||||
{ to: "/recommendations", label: "Recommendations" },
|
||||
{ to: "/queue", label: "Queue & Executions" },
|
||||
{ to: "/audit", label: "Audit Trail" }
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [token, setTokenState] = useState<string | null>(() => getToken());
|
||||
const [username, setUsername] = useState("owner");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [bootstrapPassword, setBootstrapPassword] = useState("");
|
||||
const [bootstrapUri, setBootstrapUri] = useState<string>("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const authenticated = useMemo(() => Boolean(token), [token]);
|
||||
|
||||
async function onLogin(event: FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.login(username, password, mfaCode);
|
||||
setToken(result.token);
|
||||
setTokenState(result.token);
|
||||
setPassword("");
|
||||
setMfaCode("");
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onBootstrap(event: FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.bootstrap(username, bootstrapPassword);
|
||||
setBootstrapUri(result.otpProvisioningUri);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
clearToken();
|
||||
setTokenState(null);
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-card">
|
||||
<h1>UNFI Security Copilot</h1>
|
||||
<p>LAN-only UDM Pro SE security automation dashboard.</p>
|
||||
<form onSubmit={onLogin}>
|
||||
<label>
|
||||
Username
|
||||
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
MFA code
|
||||
<input
|
||||
value={mfaCode}
|
||||
onChange={(event) => setMfaCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
<hr />
|
||||
<form onSubmit={onBootstrap}>
|
||||
<h2>Bootstrap owner (first run only)</h2>
|
||||
<label>
|
||||
Initial password
|
||||
<input
|
||||
type="password"
|
||||
value={bootstrapPassword}
|
||||
onChange={(event) => setBootstrapPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Create owner</button>
|
||||
</form>
|
||||
{bootstrapUri ? <pre>{bootstrapUri}</pre> : null}
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1>UNFI Security Copilot</h1>
|
||||
<p>Realtime UDM Pro SE hardening console</p>
|
||||
</div>
|
||||
<button onClick={logout}>Sign Out</button>
|
||||
</header>
|
||||
<nav className="main-nav">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<section className="app-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/logs" replace />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/posture" element={<PosturePage />} />
|
||||
<Route path="/recommendations" element={<RecommendationsPage />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
</Routes>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/lib/api.ts
Normal file
123
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Recommendation, RemediationAction } from "@unfi/contracts";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||
const TOKEN_KEY = "unfi_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return {
|
||||
"content-type": "application/json"
|
||||
};
|
||||
}
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers(),
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`${response.status}: ${body}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async bootstrap(username: string, password: string): Promise<{ username: string; otpProvisioningUri: string }> {
|
||||
return request("/api/v1/auth/bootstrap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
},
|
||||
|
||||
async login(username: string, password: string, mfaCode: string): Promise<{ token: string }> {
|
||||
return request("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password, mfaCode })
|
||||
});
|
||||
},
|
||||
|
||||
async dependencies(): Promise<unknown> {
|
||||
return request("/api/v1/health/dependencies");
|
||||
},
|
||||
|
||||
async posture(): Promise<unknown> {
|
||||
return request("/api/v1/security/posture");
|
||||
},
|
||||
|
||||
async recommendations(): Promise<{ recommendations: Recommendation[]; model: string; generatedAt: string | null }> {
|
||||
return request("/api/v1/security/recommendations");
|
||||
},
|
||||
|
||||
async enqueueActions(requestedBy: string, actions: RemediationAction[]): Promise<{ enqueued: number }> {
|
||||
return request("/api/v1/remediation/queue", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestedBy, actions })
|
||||
});
|
||||
},
|
||||
|
||||
async applyQueue(requestedBy: string): Promise<{ scheduled: number; blockedReason: string | null }> {
|
||||
return request("/api/v1/remediation/queue/apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestedBy })
|
||||
});
|
||||
},
|
||||
|
||||
async execution(executionId: string): Promise<unknown> {
|
||||
return request(`/api/v1/remediation/executions/${executionId}`);
|
||||
},
|
||||
|
||||
async audit(): Promise<unknown[]> {
|
||||
return request("/api/v1/audit/events");
|
||||
},
|
||||
|
||||
async notifications(): Promise<unknown[]> {
|
||||
return request("/api/v1/notifications");
|
||||
},
|
||||
|
||||
async testEmail(): Promise<{ sent: boolean }> {
|
||||
return request("/api/v1/alerts/test-email", {
|
||||
method: "POST"
|
||||
});
|
||||
},
|
||||
|
||||
async connectUdm(input: {
|
||||
baseUrl: string;
|
||||
site: string;
|
||||
username: string;
|
||||
password: string;
|
||||
verifyTls: boolean;
|
||||
}): Promise<{ connected: boolean }> {
|
||||
return request("/api/v1/udm/connect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
},
|
||||
|
||||
realtimeSocketUrl(): string {
|
||||
const token = getToken();
|
||||
const base = API_BASE ? API_BASE.replace(/^http/, "ws") : `${window.location.origin.replace(/^http/, "ws")}`;
|
||||
return `${base}/api/v1/logs/realtime?token=${encodeURIComponent(token ?? "")}`;
|
||||
}
|
||||
};
|
||||
13
apps/web/src/main.tsx
Normal file
13
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
61
apps/web/src/pages/AuditPage.tsx
Normal file
61
apps/web/src/pages/AuditPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
interface AuditEvent {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
actor: string;
|
||||
actorRole: string;
|
||||
eventType: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export function AuditPage() {
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [notifications, setNotifications] = useState<Array<{ id: string; severity: string; title: string; body: string }>>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [auditEvents, alertEvents] = await Promise.all([api.audit(), api.notifications()]);
|
||||
setEvents(auditEvents as AuditEvent[]);
|
||||
setNotifications(alertEvents as Array<{ id: string; severity: string; title: string; body: string }>);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>Audit Trail</h2>
|
||||
</header>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
<div className="card-grid">
|
||||
<section className="card">
|
||||
<h3>Audit events</h3>
|
||||
<ul>
|
||||
{events.map((event) => (
|
||||
<li key={event.id}>
|
||||
{new Date(event.occurredAt).toLocaleString()} | {event.actor} ({event.actorRole}) | {event.eventType}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h3>In-app alerts</h3>
|
||||
<ul>
|
||||
{notifications.map((note) => (
|
||||
<li key={note.id}>
|
||||
[{note.severity}] {note.title} - {note.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/pages/LogsPage.tsx
Normal file
62
apps/web/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { NormalizedEvent } from "@unfi/contracts";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function LogsPage() {
|
||||
const [events, setEvents] = useState<NormalizedEvent[]>([]);
|
||||
const [status, setStatus] = useState("connecting");
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(api.realtimeSocketUrl());
|
||||
socket.onopen = () => setStatus("connected");
|
||||
socket.onclose = () => setStatus("disconnected");
|
||||
socket.onerror = () => setStatus("error");
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { payload?: NormalizedEvent[] };
|
||||
const delta = payload.payload ?? [];
|
||||
setEvents((previous) => [...delta, ...previous].slice(0, 300));
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(() => events.slice().sort((a, b) => b.timestamp.localeCompare(a.timestamp)), [events]);
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>Realtime Logs</h2>
|
||||
<span className={`pill ${status === "connected" ? "pill-ok" : "pill-warn"}`}>{status}</span>
|
||||
</header>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Severity</th>
|
||||
<th>Source</th>
|
||||
<th>Category</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{new Date(row.timestamp).toLocaleString()}</td>
|
||||
<td>{row.severity}</td>
|
||||
<td>{row.source}</td>
|
||||
<td>{row.category}</td>
|
||||
<td>{row.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/pages/PosturePage.tsx
Normal file
87
apps/web/src/pages/PosturePage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SecurityPosture } from "@unfi/contracts";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
interface DependencyHealth {
|
||||
modelGateReady: boolean;
|
||||
issues: string[];
|
||||
udm: {
|
||||
controllerReachable: boolean;
|
||||
firmwareChannel: string;
|
||||
firmwareVersion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function PosturePage() {
|
||||
const [posture, setPosture] = useState<SecurityPosture | null>(null);
|
||||
const [deps, setDeps] = useState<DependencyHealth | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [postureData, dependencyData] = await Promise.all([api.posture(), api.dependencies()]);
|
||||
setPosture(postureData as SecurityPosture);
|
||||
setDeps(dependencyData as DependencyHealth);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>Security Posture</h2>
|
||||
{posture ? <strong className="score">{posture.score}/100</strong> : null}
|
||||
</header>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{deps ? (
|
||||
<div className="status-grid">
|
||||
<p>
|
||||
UDM Reachable: <strong>{deps.udm.controllerReachable ? "yes" : "no"}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Model Gate: <strong>{deps.modelGateReady ? "ready" : "blocked"}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Firmware Channel: <strong>{deps.udm.firmwareChannel}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Firmware Version: <strong>{deps.udm.firmwareVersion ?? "unknown"}</strong>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{posture ? (
|
||||
<div className="card-grid">
|
||||
<section className="card">
|
||||
<h3>Failed Controls</h3>
|
||||
<ul>
|
||||
{posture.failedControls.map((check) => (
|
||||
<li key={check.controlId}>{`${check.controlId}: ${check.title}`}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h3>Warnings</h3>
|
||||
<ul>
|
||||
{posture.warningControls.map((check) => (
|
||||
<li key={check.controlId}>{`${check.controlId}: ${check.title}`}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h3>Drift</h3>
|
||||
<ul>
|
||||
{posture.driftSummary.map((entry) => (
|
||||
<li key={entry}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<p>No posture data available yet. Wait for worker analysis cycle.</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/pages/QueuePage.tsx
Normal file
94
apps/web/src/pages/QueuePage.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import type { Recommendation } from "@unfi/contracts";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function QueuePage() {
|
||||
const [requestedBy, setRequestedBy] = useState("owner");
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [executionId, setExecutionId] = useState("");
|
||||
const [executionResult, setExecutionResult] = useState<string>("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const response = await api.recommendations();
|
||||
setRecommendations(response.recommendations);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function enqueueLowRisk(): Promise<void> {
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
const actions = recommendations.flatMap((recommendation) =>
|
||||
recommendation.actions.filter((action) => action.riskLevel === "low" && !action.disruptive && action.reversible)
|
||||
);
|
||||
const result = await api.enqueueActions(requestedBy, actions);
|
||||
setMessage(`Enqueued ${result.enqueued} actions`);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyQueue(): Promise<void> {
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.applyQueue(requestedBy);
|
||||
setMessage(result.blockedReason ? `Blocked: ${result.blockedReason}` : `Scheduled ${result.scheduled} items`);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupExecution(event: FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
setExecutionResult("");
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.execution(executionId);
|
||||
setExecutionResult(JSON.stringify(result, null, 2));
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestEmail(): Promise<void> {
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
await api.testEmail();
|
||||
setMessage("SMTP test email sent");
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>Queue & Executions</h2>
|
||||
</header>
|
||||
<label>
|
||||
Requested by
|
||||
<input value={requestedBy} onChange={(event) => setRequestedBy(event.target.value)} />
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button onClick={enqueueLowRisk}>Enqueue low-risk actions</button>
|
||||
<button onClick={applyQueue}>Apply Queue</button>
|
||||
<button onClick={sendTestEmail}>Send test alert email</button>
|
||||
</div>
|
||||
<form onSubmit={lookupExecution}>
|
||||
<label>
|
||||
Execution ID lookup
|
||||
<input value={executionId} onChange={(event) => setExecutionId(event.target.value)} />
|
||||
</label>
|
||||
<button type="submit">Fetch execution</button>
|
||||
</form>
|
||||
{message ? <p className="success-text">{message}</p> : null}
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{executionResult ? <pre>{executionResult}</pre> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/pages/RecommendationsPage.tsx
Normal file
52
apps/web/src/pages/RecommendationsPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Recommendation } from "@unfi/contracts";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function RecommendationsPage() {
|
||||
const [model, setModel] = useState("unknown");
|
||||
const [generatedAt, setGeneratedAt] = useState<string | null>(null);
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await api.recommendations();
|
||||
setRecommendations(response.recommendations);
|
||||
setModel(response.model);
|
||||
setGeneratedAt(response.generatedAt);
|
||||
} catch (caught) {
|
||||
setError((caught as Error).message);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<article className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>Recommendations</h2>
|
||||
<div className="meta">
|
||||
<span>model: {model}</span>
|
||||
<span>generated: {generatedAt ? new Date(generatedAt).toLocaleString() : "n/a"}</span>
|
||||
</div>
|
||||
</header>
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
<div className="card-grid">
|
||||
{recommendations.map((recommendation) => (
|
||||
<section className="card" key={recommendation.id}>
|
||||
<h3>{recommendation.title}</h3>
|
||||
<p>{recommendation.rationale}</p>
|
||||
<p>
|
||||
risk: <strong>{recommendation.riskLevel}</strong> | source: {recommendation.source}
|
||||
</p>
|
||||
<ul>
|
||||
{recommendation.actions.map((action) => (
|
||||
<li key={action.id}>{action.description}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
249
apps/web/src/styles.css
Normal file
249
apps/web/src/styles.css
Normal file
@@ -0,0 +1,249 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||
|
||||
:root {
|
||||
--bg-top: #0f172a;
|
||||
--bg-bottom: #1f2937;
|
||||
--card: rgba(2, 6, 23, 0.7);
|
||||
--card-border: rgba(148, 163, 184, 0.22);
|
||||
--text: #e2e8f0;
|
||||
--text-dim: #94a3b8;
|
||||
--accent: #14b8a6;
|
||||
--warn: #f59e0b;
|
||||
--danger: #f43f5e;
|
||||
--ok: #22c55e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(100rem 55rem at 0% 0%, rgba(20, 184, 166, 0.18), transparent),
|
||||
radial-gradient(80rem 30rem at 100% 0%, rgba(244, 63, 94, 0.13), transparent),
|
||||
linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.35rem 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
color: var(--text);
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid rgba(20, 184, 166, 0.4);
|
||||
border-radius: 0.6rem;
|
||||
color: var(--text);
|
||||
background: rgba(20, 184, 166, 0.17);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(20, 184, 166, 0.25);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.auth-shell,
|
||||
.app-shell {
|
||||
width: min(1220px, calc(100vw - 2rem));
|
||||
margin: 1rem auto 2rem;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.auth-card,
|
||||
.panel,
|
||||
.card {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(550px, 100%);
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 0.2rem 1rem;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
display: block;
|
||||
padding: 0.8rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.main-nav a.active {
|
||||
color: var(--text);
|
||||
border-color: rgba(20, 184, 166, 0.48);
|
||||
background: rgba(20, 184, 166, 0.18);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.score {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
margin-top: 0.9rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.card ul,
|
||||
.panel ul {
|
||||
margin: 0.7rem 0 0;
|
||||
padding-left: 1.05rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pill-ok {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.pill-warn {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 0.7rem;
|
||||
background: rgba(2, 6, 23, 0.88);
|
||||
overflow: auto;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.main-nav {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
19
apps/web/tsconfig.node.json
Normal file
19
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
20
apps/web/vite.config.ts
Normal file
20
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173
|
||||
}
|
||||
});
|
||||
16
apps/worker/Dockerfile
Normal file
16
apps/worker/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /workspace
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY apps/worker/package.json apps/worker/tsconfig.json ./apps/worker/
|
||||
COPY apps/worker/src ./apps/worker/src
|
||||
RUN npm install
|
||||
RUN npm run -w @unfi/worker build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /workspace/package.json ./
|
||||
COPY --from=build /workspace/node_modules ./node_modules
|
||||
COPY --from=build /workspace/apps/worker/package.json ./apps/worker/package.json
|
||||
COPY --from=build /workspace/apps/worker/dist ./apps/worker/dist
|
||||
CMD ["node", "apps/worker/dist/index.js"]
|
||||
27
apps/worker/package.json
Normal file
27
apps/worker/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@unfi/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "echo \"No linter configured for worker\"",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.2",
|
||||
"ioredis": "^5.8.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.4.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
100
apps/worker/src/index.ts
Normal file
100
apps/worker/src/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { setInterval as setIntervalAsync } from "node:timers";
|
||||
import { config as loadDotEnv } from "dotenv";
|
||||
import { Redis } from "ioredis";
|
||||
import { z } from "zod";
|
||||
|
||||
loadDotEnv();
|
||||
|
||||
const envSchema = z.object({
|
||||
API_INTERNAL_URL: z.string().url().default("http://localhost:8080"),
|
||||
WORKER_SHARED_SECRET: z.string().default("local-worker-secret"),
|
||||
REDIS_URL: z.string().url(),
|
||||
WORKER_POLL_SECONDS: z.coerce.number().positive().default(10)
|
||||
});
|
||||
const env = envSchema.parse(process.env);
|
||||
|
||||
const redis = new Redis(env.REDIS_URL);
|
||||
const workerId = `worker-${process.pid}`;
|
||||
const lockKey = "unfi:worker:leader";
|
||||
const lockTtlMs = 15_000;
|
||||
|
||||
let isLeader = false;
|
||||
|
||||
async function maintainLeadership(): Promise<void> {
|
||||
const acquired = await redis.set(lockKey, workerId, "PX", lockTtlMs, "NX");
|
||||
if (acquired === "OK") {
|
||||
isLeader = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await redis.get(lockKey);
|
||||
if (current === workerId) {
|
||||
await redis.pexpire(lockKey, lockTtlMs);
|
||||
isLeader = true;
|
||||
return;
|
||||
}
|
||||
isLeader = false;
|
||||
}
|
||||
|
||||
async function callInternalJob(pathname: string): Promise<void> {
|
||||
if (!isLeader) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${env.API_INTERNAL_URL}${pathname}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-worker-secret": env.WORKER_SHARED_SECRET
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.error(`[worker] Job ${pathname} failed: ${response.status} ${body}`);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
console.log(`[worker] Job ${pathname} ->`, payload);
|
||||
} catch (error) {
|
||||
console.error(`[worker] Job ${pathname} error`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
await maintainLeadership();
|
||||
|
||||
setIntervalAsync(async () => {
|
||||
await maintainLeadership();
|
||||
}, 5_000);
|
||||
|
||||
setIntervalAsync(async () => {
|
||||
await callInternalJob("/api/v1/internal/jobs/ingest");
|
||||
}, 3_000);
|
||||
|
||||
setIntervalAsync(async () => {
|
||||
await callInternalJob("/api/v1/internal/jobs/execute");
|
||||
}, env.WORKER_POLL_SECONDS * 1000);
|
||||
|
||||
setIntervalAsync(async () => {
|
||||
await callInternalJob("/api/v1/internal/jobs/analyze");
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
await callInternalJob("/api/v1/internal/jobs/ingest");
|
||||
await callInternalJob("/api/v1/internal/jobs/analyze");
|
||||
await callInternalJob("/api/v1/internal/jobs/execute");
|
||||
}
|
||||
|
||||
run().catch(async (error) => {
|
||||
console.error("[worker] Fatal error", error);
|
||||
await redis.quit();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, async () => {
|
||||
if (isLeader) {
|
||||
await redis.del(lockKey);
|
||||
}
|
||||
await redis.quit();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
14
apps/worker/tsconfig.json
Normal file
14
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
73
docker-compose.yml
Normal file
73
docker-compose.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-unfi_agent}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-unfi_agent}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-unfi_agent}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgres://unfi_agent:unfi_agent@postgres:5432/unfi_agent}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
API_PORT: 8080
|
||||
API_INTERNAL_URL: http://api:8080
|
||||
WORKER_SHARED_SECRET: ${WORKER_SHARED_SECRET:-local-worker-secret}
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./secrets/unfi_encryption_key:/run/secrets/unfi_encryption_key:ro
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/worker/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgres://unfi_agent:unfi_agent@postgres:5432/unfi_agent}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
API_INTERNAL_URL: http://api:8080
|
||||
WORKER_SHARED_SECRET: ${WORKER_SHARED_SECRET:-local-worker-secret}
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/web/Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "5173:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
4092
package-lock.json
generated
Normal file
4092
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "unfi-agent",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "UDM Pro SE Security Copilot",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run -w @unfi/contracts build && npm run -w @unfi/api build && npm run -w @unfi/worker build && npm run -w @unfi/web build",
|
||||
"dev": "concurrently \"npm run -w @unfi/contracts dev\" \"npm run dev:api\" \"npm run dev:worker\" \"npm run dev:web\"",
|
||||
"dev:api": "npm run -w @unfi/api dev",
|
||||
"dev:worker": "npm run -w @unfi/worker dev",
|
||||
"dev:web": "npm run -w @unfi/web dev",
|
||||
"lint": "npm run -w @unfi/contracts lint && npm run -w @unfi/api lint && npm run -w @unfi/worker lint && npm run -w @unfi/web lint",
|
||||
"test": "npm run -w @unfi/contracts test && npm run -w @unfi/api test && npm run -w @unfi/worker test && npm run -w @unfi/web test",
|
||||
"typecheck": "npm run -w @unfi/contracts typecheck && npm run -w @unfi/contracts build && npm run -w @unfi/api typecheck && npm run -w @unfi/worker typecheck && npm run -w @unfi/web typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
24
packages/contracts/package.json
Normal file
24
packages/contracts/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@unfi/contracts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -w -p tsconfig.json",
|
||||
"lint": "echo \"No linter configured for contracts\"",
|
||||
"test": "echo \"No tests for contracts\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.1.5"
|
||||
}
|
||||
}
|
||||
184
packages/contracts/src/index.ts
Normal file
184
packages/contracts/src/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const riskLevelSchema = z.enum(["low", "medium", "high"]);
|
||||
export type RiskLevel = z.infer<typeof riskLevelSchema>;
|
||||
|
||||
export const eventSeveritySchema = z.enum(["debug", "info", "warn", "error", "critical"]);
|
||||
export type EventSeverity = z.infer<typeof eventSeveritySchema>;
|
||||
|
||||
export const normalizedEventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
timestamp: z.string().datetime(),
|
||||
source: z.enum(["unifi_api", "syslog"]),
|
||||
severity: eventSeveritySchema,
|
||||
category: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
fingerprint: z.string().min(1),
|
||||
metadata: z.record(z.string(), z.unknown()).default({})
|
||||
});
|
||||
export type NormalizedEvent = z.infer<typeof normalizedEventSchema>;
|
||||
|
||||
export const udmConfigSnapshotSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
capturedAt: z.string().datetime(),
|
||||
firmwareVersion: z.string().min(1),
|
||||
firmwareChannel: z.enum(["stable", "early_access", "unknown"]).default("unknown"),
|
||||
site: z.string().min(1),
|
||||
settings: z.record(z.string(), z.unknown()),
|
||||
hash: z.string().min(1)
|
||||
});
|
||||
export type UdmConfigSnapshot = z.infer<typeof udmConfigSnapshotSchema>;
|
||||
|
||||
export const policyCheckResultSchema = z.object({
|
||||
controlId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
status: z.enum(["pass", "fail", "warn"]),
|
||||
severity: riskLevelSchema,
|
||||
evidence: z.array(z.string()).default([]),
|
||||
recommendedActionIds: z.array(z.string()).default([])
|
||||
});
|
||||
export type PolicyCheckResult = z.infer<typeof policyCheckResultSchema>;
|
||||
|
||||
export const securityPostureSchema = z.object({
|
||||
score: z.number().min(0).max(100),
|
||||
evaluatedAt: z.string().datetime(),
|
||||
failedControls: z.array(policyCheckResultSchema),
|
||||
warningControls: z.array(policyCheckResultSchema),
|
||||
passedControls: z.array(policyCheckResultSchema),
|
||||
driftDetected: z.boolean(),
|
||||
driftSummary: z.array(z.string()).default([])
|
||||
});
|
||||
export type SecurityPosture = z.infer<typeof securityPostureSchema>;
|
||||
|
||||
export const remediationActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
controlId: z.string().min(1),
|
||||
type: z.enum(["set_config", "disable_feature", "enable_feature", "notify_only"]),
|
||||
description: z.string().min(1),
|
||||
riskLevel: riskLevelSchema,
|
||||
disruptive: z.boolean().default(false),
|
||||
reversible: z.boolean().default(true),
|
||||
payload: z.record(z.string(), z.unknown()).default({}),
|
||||
expectedState: z.record(z.string(), z.unknown()).default({})
|
||||
});
|
||||
export type RemediationAction = z.infer<typeof remediationActionSchema>;
|
||||
|
||||
export const recommendationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
source: z.enum(["ai", "rule"]),
|
||||
title: z.string().min(1),
|
||||
rationale: z.string().min(1),
|
||||
riskLevel: riskLevelSchema,
|
||||
controls: z.array(z.string()).default([]),
|
||||
actions: z.array(remediationActionSchema).default([]),
|
||||
createdAt: z.string().datetime()
|
||||
});
|
||||
export type Recommendation = z.infer<typeof recommendationSchema>;
|
||||
|
||||
export const recommendationSetSchema = z.object({
|
||||
generatedAt: z.string().datetime(),
|
||||
model: z.string().min(1),
|
||||
recommendations: z.array(recommendationSchema)
|
||||
});
|
||||
export type RecommendationSet = z.infer<typeof recommendationSetSchema>;
|
||||
|
||||
export const executionPlanStatusSchema = z.enum([
|
||||
"queued",
|
||||
"pending_apply",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"rolled_back",
|
||||
"blocked"
|
||||
]);
|
||||
export type ExecutionPlanStatus = z.infer<typeof executionPlanStatusSchema>;
|
||||
|
||||
export const executionPlanSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
createdAt: z.string().datetime(),
|
||||
status: executionPlanStatusSchema,
|
||||
requestedBy: z.string().min(1),
|
||||
actionIds: z.array(z.string()).default([])
|
||||
});
|
||||
export type ExecutionPlan = z.infer<typeof executionPlanSchema>;
|
||||
|
||||
export const backupSnapshotResultSchema = z.object({
|
||||
backupId: z.string().min(1),
|
||||
createdAt: z.string().datetime(),
|
||||
location: z.string().min(1)
|
||||
});
|
||||
export type BackupSnapshotResult = z.infer<typeof backupSnapshotResultSchema>;
|
||||
|
||||
export const executionRecordSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
planId: z.string().min(1),
|
||||
startedAt: z.string().datetime(),
|
||||
finishedAt: z.string().datetime().nullable(),
|
||||
status: executionPlanStatusSchema,
|
||||
backup: backupSnapshotResultSchema.optional(),
|
||||
verificationPassed: z.boolean().default(false),
|
||||
rollbackPerformed: z.boolean().default(false),
|
||||
evidence: z.array(z.string()).default([]),
|
||||
error: z.string().nullable().default(null)
|
||||
});
|
||||
export type ExecutionRecord = z.infer<typeof executionRecordSchema>;
|
||||
|
||||
export const capabilityReportSchema = z.object({
|
||||
checkedAt: z.string().datetime(),
|
||||
controllerReachable: z.boolean(),
|
||||
firmwareVersion: z.string().optional(),
|
||||
firmwareChannel: z.enum(["stable", "early_access", "unknown"]),
|
||||
modelGateReady: z.boolean(),
|
||||
features: z.object({
|
||||
eventsApi: z.boolean(),
|
||||
configRead: z.boolean(),
|
||||
backupCreate: z.boolean(),
|
||||
actionApply: z.boolean(),
|
||||
syslogFallback: z.boolean()
|
||||
}),
|
||||
issues: z.array(z.string()).default([])
|
||||
});
|
||||
export type CapabilityReport = z.infer<typeof capabilityReportSchema>;
|
||||
|
||||
export const securityAnalysisInputSchema = z.object({
|
||||
posture: securityPostureSchema,
|
||||
recentEvents: z.array(normalizedEventSchema).max(1000),
|
||||
snapshot: udmConfigSnapshotSchema,
|
||||
policyVersion: z.string().min(1)
|
||||
});
|
||||
export type SecurityAnalysisInput = z.infer<typeof securityAnalysisInputSchema>;
|
||||
|
||||
export const auditEventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
occurredAt: z.string().datetime(),
|
||||
actor: z.string().min(1),
|
||||
actorRole: z.enum(["owner", "operator", "viewer", "system"]),
|
||||
eventType: z.string().min(1),
|
||||
entityType: z.string().min(1),
|
||||
entityId: z.string().min(1),
|
||||
payload: z.record(z.string(), z.unknown()).default({}),
|
||||
prevHash: z.string().nullable().default(null),
|
||||
hash: z.string().min(1)
|
||||
});
|
||||
export type AuditEvent = z.infer<typeof auditEventSchema>;
|
||||
|
||||
export const udmConnectRequestSchema = z.object({
|
||||
baseUrl: z.string().url(),
|
||||
site: z.string().default("default"),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
verifyTls: z.boolean().default(true)
|
||||
});
|
||||
export type UdmConnectRequest = z.infer<typeof udmConnectRequestSchema>;
|
||||
|
||||
export const remediationQueueRequestSchema = z.object({
|
||||
requestedBy: z.string().min(1),
|
||||
actions: z.array(remediationActionSchema).min(1)
|
||||
});
|
||||
export type RemediationQueueRequest = z.infer<typeof remediationQueueRequestSchema>;
|
||||
|
||||
export const remediationApplyRequestSchema = z.object({
|
||||
requestedBy: z.string().min(1)
|
||||
});
|
||||
export type RemediationApplyRequest = z.infer<typeof remediationApplyRequestSchema>;
|
||||
10
packages/contracts/tsconfig.json
Normal file
10
packages/contracts/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
20
tsconfig.base.json
Normal file
20
tsconfig.base.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user