diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 3e232fe..0de1b51 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -21,11 +21,11 @@ This document details the operational baseline, hardening criteria, and verifica ## 2. Infrastructure & Access Management Configurations ### Wallet Allowlisting -* **Operator Wallet Configuration:** Explicitly restrict administrative and operational transaction capabilities using a static allowlist environment string. +* **Operator Wallet Configuration:** Explicitly restrict administrative and operational transaction capabilities using a static allowlist environment string. * **Zero Wildcards:** Set the `WALLET_ALLOWLIST` value to exact Stellar public keys. Never leave this parameter blank or wildcarded (`*`) in production. ### Authentication Hardening -* All deployment authentication tokens must rely on a cryptographically secure `JWT_SECRET`. +* All deployment authentication tokens must rely on a cryptographically secure `JWT_SECRET`. * Rotate keys periodically via an automated pipeline without bringing down execution engines. ### Storage Configurations diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 2b044f4..e6b9acf 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -103,8 +103,6 @@ function resolveTargets() { */ function extractLinks(text) { const links = []; - const lines = text.split('\n'); - // Strip HTML comment blocks to avoid false positives const stripped = text.replace(//g, (m) => ' '.repeat(m.length)); // Strip fenced code blocks diff --git a/src/app/api/audit/export/route.test.ts b/src/app/api/audit/export/route.test.ts index b202fec..17a7fca 100644 --- a/src/app/api/audit/export/route.test.ts +++ b/src/app/api/audit/export/route.test.ts @@ -163,7 +163,7 @@ describe("/api/audit/export route", () => { }); it("redacts entriesByUser on all-scope JSON exports for operators", async () => { - const request = new NextRequest( + const req = new NextRequest( "http://localhost/api/audit/export?format=json&scope=all", { method: "GET", @@ -173,6 +173,8 @@ describe("/api/audit/export route", () => { } ); + const response = await GET(req); + expect(response.status).toBe(200); const payload = (await response.json()) as { diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index fe967b7..a192bee 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -2,7 +2,6 @@ import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { POST } from "./route"; vi.mock("@/lib/auth/require-auth", () => ({ @@ -85,7 +84,6 @@ import { requireAuth } from "@/lib/auth/require-auth"; import { readJsonBody } from "@/lib/http/read-json-body"; import { getUserWallet } from "@/lib/storage/user-wallet-store"; import { stellarSubmitSignedRequestSchema } from "@/lib/validation/schemas"; -import { POST } from "./route"; function buildSignedXdr(signerKp: Keypair, sourcePublicKey: string) { const account = new Account(sourcePublicKey, "1"); @@ -200,24 +198,16 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => { }); }); -function setupSecret() { - process.env.FORTEXA_AUTH_SECRET = "integration-test-secret"; -} - -function viewerCookie() { - setupSecret(); - const token = createSessionToken({ - email: "viewer@fortexa.local", - role: "viewer", - userId: "submit-viewer-id", - expiresInSeconds: 120, - }); - - return `${AUTH_COOKIE_KEY}=${token}`; -} - describe("POST /api/stellar/submit-signed authorization", () => { it("returns 401 when unauthenticated", async () => { + vi.mocked(requireAuth).mockReturnValueOnce({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized. Login required." }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + } as ReturnType); + const request = new NextRequest("http://localhost/api/stellar/submit-signed", { method: "POST", headers: { "content-type": "application/json" }, @@ -229,12 +219,17 @@ describe("POST /api/stellar/submit-signed authorization", () => { }); it("returns 403 for viewer role (operator-only route)", async () => { + vi.mocked(requireAuth).mockReturnValueOnce({ + ok: false, + response: new Response(JSON.stringify({ error: "Forbidden. Insufficient role permissions." }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + } as ReturnType); + const request = new NextRequest("http://localhost/api/stellar/submit-signed", { method: "POST", - headers: { - "content-type": "application/json", - cookie: viewerCookie(), - }, + headers: { "content-type": "application/json" }, body: JSON.stringify({ signedXdr: "AAAA" }), }); diff --git a/src/components/decision-console.tsx b/src/components/decision-console.tsx index e78e81b..205951d 100644 --- a/src/components/decision-console.tsx +++ b/src/components/decision-console.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Loader2, Sparkles, @@ -138,12 +138,6 @@ export function DecisionConsole() { Number.isFinite(parsedExecuteAmount) && parsedExecuteAmount > 0 ? parsedExecuteAmount : evaluatedAmount; const destinationPreview = destination.trim().toUpperCase(); - useEffect(() => { - if (step === 4 && evaluatedAmount != null && !executeAmount) { - setExecuteAmount(String(evaluatedAmount)); - } - }, [step, evaluatedAmount, executeAmount]); - function resetPreparedXdr() { setUnsignedXdr(""); setSignedXdrInput(""); @@ -243,7 +237,11 @@ export function DecisionConsole() { setAuthorizedAuditEntryId(payload.auditEntry.id); setMessage("Decision recorded in audit trail."); pushToast("success", "Evaluation complete."); - setStep(payload.result.decision === "REQUIRE_APPROVAL" ? 3 : payload.result.decision === "BLOCK" ? 2 : 4); + const nextStep = payload.result.decision === "REQUIRE_APPROVAL" ? 3 : payload.result.decision === "BLOCK" ? 2 : 4; + if (nextStep === 4 && evaluatedAmount != null && !executeAmount) { + setExecuteAmount(String(evaluatedAmount)); + } + setStep(nextStep); } catch (error) { const err = error instanceof Error ? error.message : "Unexpected failure."; setMessage(err); diff --git a/src/components/ops-dashboard.tsx b/src/components/ops-dashboard.tsx index 8d7e308..dcb6d70 100644 --- a/src/components/ops-dashboard.tsx +++ b/src/components/ops-dashboard.tsx @@ -234,6 +234,15 @@ export function OpsDashboard() {
{health?.timestamp ?? "-"}
+ {lastRefreshed ? ( +
+ Last refreshed: {formatShortTime(lastRefreshed)} +
+ ) : null} {health?.dependencies ? (
diff --git a/src/components/policy-editor.tsx b/src/components/policy-editor.tsx index 68cb008..704593b 100644 --- a/src/components/policy-editor.tsx +++ b/src/components/policy-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { History } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -108,7 +108,7 @@ export function PolicyEditor() { }; } - async function loadPolicy() { + const loadPolicy = useCallback(async () => { setLoading(true); try { const response = await fetch("/api/policy", { cache: "no-store" }); @@ -133,7 +133,7 @@ export function PolicyEditor() { } finally { setLoading(false); } - } + }, []); /** * Pull the latest server version and replace the editor draft with it. @@ -297,7 +297,7 @@ export function PolicyEditor() { } } - async function loadHistory() { + const loadHistory = useCallback(async () => { try { const response = await fetch("/api/policy/history?limit=8", { cache: "no-store" }); const payload = (await response.json()) as PolicyHistoryResponse; @@ -310,7 +310,7 @@ export function PolicyEditor() { } catch { setHistory([]); } - } + }, []); async function previewRollback(versionToPreview: number) { if (!isOperator) { @@ -442,9 +442,10 @@ export function PolicyEditor() { } useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount void loadPolicy(); void loadHistory(); - }, []); + }, [loadPolicy, loadHistory]); return (
@@ -635,7 +636,9 @@ export function PolicyEditor() {
diff --git a/src/components/wallet-status-card.tsx b/src/components/wallet-status-card.tsx index 0689763..3b5104d 100644 --- a/src/components/wallet-status-card.tsx +++ b/src/components/wallet-status-card.tsx @@ -25,7 +25,39 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) { const [copied, setCopied] = useState(false); const copyResetTimeout = useRef | null>(null); - async function loadWallet() { + useEffect(() => { + let isActive = true; + + const loadWallet = async () => { + setLoading(true); + try { + const response = await fetch("/api/stellar/balance"); + const payload = (await response.json()) as WalletData; + if (isActive) { + setData(payload); + } + } catch { + if (isActive) { + setData(null); + } + } finally { + if (isActive) { + setLoading(false); + } + } + }; + + void loadWallet(); + + return () => { + isActive = false; + if (copyResetTimeout.current) { + clearTimeout(copyResetTimeout.current); + } + }; + }, []); + + async function handleRefresh() { setLoading(true); try { const response = await fetch("/api/stellar/balance"); @@ -38,18 +70,6 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) { } } - useEffect(() => { - void loadWallet(); - }, []); - - useEffect(() => { - return () => { - if (copyResetTimeout.current) { - clearTimeout(copyResetTimeout.current); - } - }; - }, []); - async function copyPublicKey() { if (!data?.publicKey) return; await navigator.clipboard.writeText(data.publicKey); @@ -59,7 +79,6 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) { } copyResetTimeout.current = setTimeout(() => setCopied(false), 2000); } - if (compact) { return (
@@ -83,7 +102,7 @@ export function WalletStatusCard({ compact = false }: { compact?: boolean }) {
- diff --git a/src/lib/auth/use-auth-session.ts b/src/lib/auth/use-auth-session.ts index 4c8ea0d..6ddbac3 100644 --- a/src/lib/auth/use-auth-session.ts +++ b/src/lib/auth/use-auth-session.ts @@ -62,6 +62,7 @@ export function useAuthSession() { }, []); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial session refresh on mount void refresh(); }, [refresh]); diff --git a/src/lib/decision/engine.test.ts b/src/lib/decision/engine.test.ts index b2d67d4..493023d 100644 --- a/src/lib/decision/engine.test.ts +++ b/src/lib/decision/engine.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { evaluateDecision } from "@/lib/decision/engine"; import { defaultPolicyConfig } from "@/lib/policy/engine"; import { demoScenarios, defaultDailyUsage } from "@/lib/scenarios/seed"; -import type { AgentAction, DailyUsage, DecisionResult, PolicyConfig } from "@/lib/types/domain"; +import type { DecisionResult, PolicyConfig } from "@/lib/types/domain"; const testPolicy: PolicyConfig = { ...defaultPolicyConfig, diff --git a/src/lib/security/analyzer.test.ts b/src/lib/security/analyzer.test.ts index 22bebe4..f8674e0 100644 --- a/src/lib/security/analyzer.test.ts +++ b/src/lib/security/analyzer.test.ts @@ -262,32 +262,16 @@ describe("evaluateSecurity", () => { it("marks as degraded with timeout flag when blocklist fetch times out", async () => { process.env.FORTEXA_BLOCKLIST_URL = "https://example.com/blocklist.json"; - process.env.FORTEXA_BLOCKLIST_TIMEOUT_MS = "1000"; - // Simulate timeout by making fetch never resolve and then aborting + // Simulate timeout by making fetch reject with AbortError const abortError = new Error("The operation was aborted"); abortError.name = "AbortError"; - vi.spyOn(globalThis, "fetch").mockImplementation( - () => new Promise(() => {}), // never resolves - ); - - // Use shorter timeout for test - const timeoutPromise = new Promise<{ status: "timeout" }>((resolve) => { - setTimeout(() => resolve({ status: "timeout" }), 100); - }); - - // Mock the setTimeout so we can trigger timeouts during test - vi.useFakeTimers(); vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(abortError); - const evaluationPromise = evaluateSecurity(makeAction()); - vi.runAllTimersAsync(); - - const result = await evaluationPromise; - - vi.useRealTimers(); + const result = await evaluateSecurity(makeAction()); - expect(result.analyzerStatus.blocklistStatus).toBe("error"); + expect(result.analyzerStatus.blocklistStatus).toBe("timeout"); + expect(result.analyzerStatus.blocklistTimedOut).toBe(true); expect(result.analyzerStatus.isDegraded).toBe(true); }); @@ -325,7 +309,7 @@ describe("evaluateSecurity", () => { const result = await evaluateSecurity( makeAction({ - outputPreview: "reveal secret key", + outputPreview: "share your private key", }), ); diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index 92e692e..dcc87c8 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -4,27 +4,7 @@ import type { SecurityEvaluation, SecurityFinding, } from "@/lib/types/domain"; -import { fetchBlocklist } from "@/lib/security/blocklist"; - -/** Configuration for analyzer timeout behavior. */ -export interface AnalyzerConfig { - blocklistTimeoutMs: number; -} - -/** Default analyzer configuration - 5 second timeout for blocklist fetch. */ -export const defaultAnalyzerConfig: AnalyzerConfig = { - blocklistTimeoutMs: 5000, -}; - -/** Get analyzer config from environment or use defaults. */ -function getAnalyzerConfig(): AnalyzerConfig { - return { - blocklistTimeoutMs: parseInt( - process.env.FORTEXA_BLOCKLIST_TIMEOUT_MS || "5000", - 10, - ), - }; -} +import { fetchBlocklist, getBlocklistHealth } from "@/lib/security/blocklist"; const suspiciousPatterns = [ /ignore\s+all\s+previous\s+instructions/i, @@ -158,43 +138,33 @@ function blocklistCheck( * Fetch blocklist with timeout support. Returns findings if successful, empty array if blocked/timed out/failed. * Returns status indicating what happened. */ -async function fetchBlocklistWithTimeout( - timeoutMs: number, -): Promise<{ +async function fetchBlocklistWithTimeout(): Promise<{ blocklist: string[]; status: { blocked: boolean; timedOut: boolean; error?: string }; }> { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const blocklist = await fetchBlocklist(); - clearTimeout(timeoutId); - return { blocklist, status: { blocked: false, timedOut: false } }; - } finally { - clearTimeout(timeoutId); - } - } catch (err) { - const isTimeout = err instanceof Error && err.name === "AbortError"; - const isNetworkError = - err instanceof TypeError && err.message.includes("fetch"); + const blocklist = await fetchBlocklist(); + // fetchBlocklist swallows errors internally, so check health for failures + const health = getBlocklistHealth(); + if (health.configured && health.lastError) { + const isTimeout = + /abort|timeout/i.test(health.lastError); return { - blocklist: [], + blocklist, status: { blocked: true, timedOut: isTimeout, - error: err instanceof Error ? err.message : "Unknown error", + error: health.lastError, }, }; } + + return { blocklist, status: { blocked: false, timedOut: false } }; } export async function evaluateSecurity( action: AgentAction, ): Promise { - const config = getAnalyzerConfig(); const analyzerStatus: AnalyzerStatus = { blocklistStatus: "success", isDegraded: false, @@ -210,16 +180,15 @@ export async function evaluateSecurity( // Fetch blocklist with timeout handling const { blocklist, status: blocklistFetchStatus } = - await fetchBlocklistWithTimeout(config.blocklistTimeoutMs); + await fetchBlocklistWithTimeout(); if (blocklistFetchStatus.timedOut) { analyzerStatus.blocklistStatus = "timeout"; analyzerStatus.blocklistTimedOut = true; - analyzerStatus.blocklistError = "Blocklist fetch timed out"; + analyzerStatus.blocklistError = + blocklistFetchStatus.error ?? "Blocklist fetch timed out"; analyzerStatus.isDegraded = true; - analyzerStatus.degradationReasons?.push( - `blocklist_timeout_${config.blocklistTimeoutMs}ms`, - ); + analyzerStatus.degradationReasons?.push("blocklist_timeout"); } else if (blocklistFetchStatus.blocked) { analyzerStatus.blocklistStatus = "error"; analyzerStatus.blocklistError = blocklistFetchStatus.error; diff --git a/src/modules/auth/__tests__/session-cookie.test.ts b/src/modules/auth/__tests__/session-cookie.test.ts new file mode 100644 index 0000000..3dd5817 --- /dev/null +++ b/src/modules/auth/__tests__/session-cookie.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createSessionToken, verifySessionToken, AUTH_COOKIE_KEY } from "../../../lib/auth/session"; + +describe("Session & Cookie Security Regression Tests", () => { + beforeEach(() => { + process.env.FORTEXA_AUTH_SECRET = "test-secret-key-123"; + }); + + describe("Cookie Security Flags", () => { + it("should use secure cookies in production environment", () => { + const isProd = process.env.NODE_ENV === "production"; + const secureFlag = isProd ? "Secure;" : ""; + + const mockCookie = `${AUTH_COOKIE_KEY}=mocked_token; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800; ${secureFlag}`; + + expect(mockCookie).toContain("HttpOnly"); + expect(mockCookie).toContain("SameSite=Lax"); + expect(mockCookie).toContain("Path=/"); + expect(mockCookie).toContain("Max-Age=604800"); + if (isProd) { + expect(mockCookie).toContain("Secure"); + } + }); + + it("should set Secure flag specifically when production is enforced", () => { + const secureFlag = "Secure;"; + const mockCookie = `${AUTH_COOKIE_KEY}=mocked_token; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800; ${secureFlag}`; + expect(mockCookie).toContain("Secure"); + }); + }); + + describe("Logout Behavior", () => { + it("should clear the fortexa_session cookie upon logout", () => { + const logoutCookie = `${AUTH_COOKIE_KEY}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`; + + expect(logoutCookie).toContain(`${AUTH_COOKIE_KEY}=;`); + expect(logoutCookie).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + }); + }); + + describe("Token Hardening", () => { + it("should safely reject an expired session token", () => { + const expiredToken = createSessionToken({ + email: "test@example.com", + role: "viewer", + userId: "user-1", + expiresInSeconds: -3600 + }); + + const session = verifySessionToken(expiredToken); + expect(session).toBeNull(); + }); + + it("should safely reject a tampered session token signature", () => { + const validToken = createSessionToken({ + email: "test@example.com", + role: "operator", + userId: "user-2" + }); + + const parts = validToken.split("."); + const tamperedToken = `${parts[0]}.invalid_signature_here`; + + const session = verifySessionToken(tamperedToken); + expect(session).toBeNull(); + }); + + it("should safely reject malformed session tokens", () => { + expect(verifySessionToken("not.a.real.token")).toBeNull(); + expect(verifySessionToken("just_one_part")).toBeNull(); + expect(verifySessionToken("")).toBeNull(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 00985c2..5d998c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,5 +39,3 @@ "node_modules" ] } - -