diff --git a/.gitignore b/.gitignore index 7a07c41..e5d2d04 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules/ .wrangler/ .hermes/ *.log +.env +.env.* diff --git a/the-unit/api/src/allowlist.ts b/the-unit/api/src/allowlist.ts new file mode 100644 index 0000000..5220cc4 --- /dev/null +++ b/the-unit/api/src/allowlist.ts @@ -0,0 +1,38 @@ +import type { NextFunction, Response } from "express"; +import type { AuthedRequest } from "./auth.js"; +import { logger } from "./logger.js"; + +const ALLOWED_EMAILS_RAW = process.env.ALLOWED_EMAILS ?? ""; + +const allowedEmails: Set = new Set( + ALLOWED_EMAILS_RAW.split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean), +); + +/** + * Rejects requests from users whose email is not in the allowlist. + * When ALLOWED_EMAILS is unset or empty, ALL authenticated users are blocked + * (fail-closed) to prevent open proxy abuse. + */ +export function enforceAllowlist( + req: AuthedRequest, + res: Response, + next: NextFunction, +): void { + const email = req.email?.toLowerCase() ?? ""; + + if (allowedEmails.size === 0) { + logger.warn({ uid: req.uid, email }, "allowlist_empty_all_blocked"); + res.status(403).json({ error: "service_not_configured" }); + return; + } + + if (!allowedEmails.has(email)) { + logger.warn({ uid: req.uid, email }, "user_not_in_allowlist"); + res.status(403).json({ error: "not_authorized" }); + return; + } + + next(); +} diff --git a/the-unit/api/src/index.ts b/the-unit/api/src/index.ts index a55a86b..840e031 100644 --- a/the-unit/api/src/index.ts +++ b/the-unit/api/src/index.ts @@ -1,6 +1,7 @@ import express from "express"; import { pinoHttp } from "pino-http"; import { requireFirebaseAuth } from "./auth.js"; +import { enforceAllowlist } from "./allowlist.js"; import { enforceDailyCap } from "./rateLimit.js"; import { logger } from "./logger.js"; import { anthropicRouter } from "./providers/anthropic.js"; @@ -18,10 +19,10 @@ export function buildApp(): express.Express { res.json({ ok: true, service: "gumption-api" }); }); - app.use("/v1/anthropic", requireFirebaseAuth, enforceDailyCap, anthropicRouter); - app.use("/v1/openai", requireFirebaseAuth, enforceDailyCap, openaiRouter); - app.use("/v1/xai", requireFirebaseAuth, enforceDailyCap, xaiRouter); - app.use("/v1/gemini", requireFirebaseAuth, enforceDailyCap, geminiRouter); + app.use("/v1/anthropic", requireFirebaseAuth, enforceAllowlist, enforceDailyCap, anthropicRouter); + app.use("/v1/openai", requireFirebaseAuth, enforceAllowlist, enforceDailyCap, openaiRouter); + app.use("/v1/xai", requireFirebaseAuth, enforceAllowlist, enforceDailyCap, xaiRouter); + app.use("/v1/gemini", requireFirebaseAuth, enforceAllowlist, enforceDailyCap, geminiRouter); app.use((_req, res) => { res.status(404).json({ error: "not_found" }); diff --git a/the-unit/scripts/deploy-gcp.sh b/the-unit/scripts/deploy-gcp.sh index 2f59cba..fb57324 100755 --- a/the-unit/scripts/deploy-gcp.sh +++ b/the-unit/scripts/deploy-gcp.sh @@ -41,7 +41,8 @@ for SECRET in anthropic-key:ANTHROPIC_API_KEY openai-key:OPENAI_API_KEY xai-key: done SECRETS_ARG="${SECRETS_ARG%,}" -ENV_VARS="GCP_PROJECT=${PROJECT_ID},GCP_REGION=${REGION},DAILY_TOKEN_CAP=${DAILY_TOKEN_CAP}" +ALLOWED_EMAILS="${ALLOWED_EMAILS:-bryan@norcalcarbmobile.com}" +ENV_VARS="GCP_PROJECT=${PROJECT_ID},GCP_REGION=${REGION},DAILY_TOKEN_CAP=${DAILY_TOKEN_CAP},ALLOWED_EMAILS=${ALLOWED_EMAILS}" CMD=( gcloud run deploy "$SERVICE" diff --git a/workers/silverback-ai-studio/server.ts b/workers/silverback-ai-studio/server.ts index bcd8500..255cb12 100644 --- a/workers/silverback-ai-studio/server.ts +++ b/workers/silverback-ai-studio/server.ts @@ -4,10 +4,13 @@ import { WebSocketServer, WebSocket } from "ws"; import { createServer as createViteServer } from "vite"; import path from "path"; import { fileURLToPath } from "url"; +import { GoogleGenAI } from "@google/genai"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const ALERT_SECRET = process.env.ALERT_SECRET || ""; + async function startServer() { const app = express(); const server = createServer(app); @@ -39,8 +42,48 @@ async function startServer() { }); }; - // API to trigger high-severity alerts (for testing/demo) + // Server-side AI analysis — keeps GEMINI_API_KEY off the client + app.post("/api/analyze", async (req, res) => { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + res.status(503).json({ error: "gemini_key_not_configured" }); + return; + } + + const { eventType, description, severity } = req.body; + if (!eventType || !description) { + res.status(400).json({ error: "missing_fields" }); + return; + } + + try { + const ai = new GoogleGenAI({ apiKey }); + const response = await ai.models.generateContent({ + model: "gemini-3-flash-preview", + contents: `Analyze this security event clip description and provide a more detailed, professional security assessment. + Event Type: ${eventType} + Initial Description: ${description} + Severity: ${severity || "unknown"} + + Provide a detailed breakdown of what might be happening, potential risks, and recommended actions. Keep it concise but professional.`, + }); + + const analysis = response.text || "No analysis available."; + res.json({ analysis }); + } catch (err) { + console.error("AI analysis failed:", err); + res.status(500).json({ error: "analysis_failed" }); + } + }); + + // API to trigger high-severity alerts — requires shared secret app.post("/api/trigger-alert", (req, res) => { + const authHeader = req.header("x-alert-secret") || ""; + if (!ALERT_SECRET || authHeader !== ALERT_SECRET) { + res.status(401).json({ error: "unauthorized" }); + return; + } + const { type, description, severity, location } = req.body; if (severity === 'high' || severity === 'critical') { diff --git a/workers/silverback-ai-studio/src/App.tsx b/workers/silverback-ai-studio/src/App.tsx index 76017d2..1de7f0e 100644 --- a/workers/silverback-ai-studio/src/App.tsx +++ b/workers/silverback-ai-studio/src/App.tsx @@ -540,21 +540,24 @@ export default function App() { setIsAnalyzing(event.id); try { - const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); - const response = await ai.models.generateContent({ - model: "gemini-3-flash-preview", - contents: `Analyze this security event clip description and provide a more detailed, professional security assessment. - Event Type: ${event.type} - Initial Description: ${event.description} - Severity: ${event.severity} - - Provide a detailed breakdown of what might be happening, potential risks, and recommended actions. Keep it concise but professional.`, + const res = await fetch("/api/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + eventType: event.type, + description: event.description, + severity: event.severity, + }), }); - const analysis = response.text || "No analysis available."; + if (!res.ok) { + throw new Error(`Analysis request failed: ${res.status}`); + } + + const { analysis } = await res.json(); const eventRef = doc(db, 'event_logs', event.id); - await updateDoc(eventRef, { aiAnalysis: analysis }); + await updateDoc(eventRef, { aiAnalysis: analysis || "No analysis available." }); } catch (error) { console.error("AI Analysis failed:", error); handleFirestoreError(error, OperationType.UPDATE, `event_logs/${event.id}`); diff --git a/workers/silverback-ai-studio/vite.config.ts b/workers/silverback-ai-studio/vite.config.ts index 6b1fbc3..c1d3e04 100644 --- a/workers/silverback-ai-studio/vite.config.ts +++ b/workers/silverback-ai-studio/vite.config.ts @@ -1,15 +1,11 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import path from 'path'; -import {defineConfig, loadEnv} from 'vite'; +import {defineConfig} from 'vite'; -export default defineConfig(({mode}) => { - const env = loadEnv(mode, '.', ''); +export default defineConfig(() => { return { plugins: [react(), tailwindcss()], - define: { - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), - }, resolve: { alias: { '@': path.resolve(__dirname, '.'),