From 94e4435b96c5f6609228aca3d25e6cf45bce6d5b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 09:54:20 +0000 Subject: [PATCH 1/3] security: add .env exclusion patterns to root .gitignore Prevents accidental commit of .env.local and other secret-bearing environment files from workers/silverback-ai-studio/ and repo root. The the-unit/api/.gitignore already had this but root did not. Co-authored-by: CARBComplianceApp --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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.* From 2027a217ef1f0bb5c22669e4655c60877c37ce01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 09:54:29 +0000 Subject: [PATCH 2/3] security: move Gemini API key server-side and protect trigger-alert endpoint - Remove Vite 'define' that inlined GEMINI_API_KEY into client bundle - Add /api/analyze endpoint on Express server (server-side Gemini calls) - Update App.tsx analyzeEvent() to call server endpoint via fetch - Protect /api/trigger-alert with x-alert-secret header check The API key is no longer embedded in browser-accessible JavaScript. The trigger-alert endpoint now requires a shared secret to prevent unauthenticated alert injection. Co-authored-by: CARBComplianceApp --- workers/silverback-ai-studio/server.ts | 45 ++++++++++++++++++++- workers/silverback-ai-studio/src/App.tsx | 25 +++++++----- workers/silverback-ai-studio/vite.config.ts | 8 +--- 3 files changed, 60 insertions(+), 18 deletions(-) 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, '.'), From c424795b502623ba6fd897dc964d3c94fc2091c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 09:54:39 +0000 Subject: [PATCH 3/3] security: add user email allowlist to LLM proxy API - New allowlist.ts middleware rejects users not in ALLOWED_EMAILS - Fail-closed: if ALLOWED_EMAILS is empty, all requests are blocked - Wire enforceAllowlist into all /v1/* provider routes after auth - Update deploy script to pass ALLOWED_EMAILS env var (defaults to admin) Prevents abuse by arbitrary Google accounts authenticating via Firebase and proxying paid LLM API calls (Anthropic, OpenAI, xAI, Vertex AI) at the project owner's expense. Co-authored-by: CARBComplianceApp --- the-unit/api/src/allowlist.ts | 38 ++++++++++++++++++++++++++++++++++ the-unit/api/src/index.ts | 9 ++++---- the-unit/scripts/deploy-gcp.sh | 3 ++- 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 the-unit/api/src/allowlist.ts 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"