First version
Some checks failed
CI / build-test (push) Has been cancelled

This commit is contained in:
2026-02-19 09:31:13 +01:00
commit 1941a02029
55 changed files with 8750 additions and 0 deletions

43
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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
View 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
View 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
View 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 });
}

View 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
View 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();
}
}

View 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
View 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);
}

View 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
});
}
}

View 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]
};
}
}

View 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;
}

View 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";
}

View 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
};
}
}

View 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()
};
}
}

View 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()
}));
}
}

View 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
};
}
}

View 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();
}
}

View 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
View File

@@ -0,0 +1,11 @@
import "fastify";
declare module "fastify" {
interface FastifyRequest {
user?: {
id: string;
username: string;
role: "owner" | "operator" | "viewer";
};
}
}

View 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);
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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"
]
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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"
}
}

View 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"
}
}

View 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>;

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src/**/*.ts"
]
}

20
tsconfig.base.json Normal file
View 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
}
}