diff --git a/.env.example b/.env.example index 0b587df..a2cf54b 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,25 @@ +# Development/testnet example values. +# Replace these demo defaults before deploying or enabling protected payment flows in production. + # Stellar testnet endpoints STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org # Optional override; defaults to the testnet passphrase when unset. # Must match STELLAR_HORIZON_URL (testnet vs public/mainnet). STELLAR_NETWORK_PASSPHRASE= -# Optional Postgres persistence (Sprint 3) +# Production readiness check requires one explicit persistence backend: +# - DATABASE_URL for Postgres, or +# - FORTEXA_STORE_DIR for a durable file-backed store DATABASE_URL= DATABASE_SSL=false -# Optional file-fallback storage directory +# Explicit file-backed storage directory (required in production if DATABASE_URL is unset) # Local default: .fortexa # Vercel default: /tmp/fortexa FORTEXA_STORE_DIR= +# Shared security state is required in production for lockout/rate-limit durability. +# Configure either FORTEXA_SHARED_STATE_PATH or REDIS_URL. # Optional shared security state for multi-instance lockout/rate-limit # Local example: .fortexa/shared-security-state.json # Vercel example: /tmp/fortexa/shared-security-state.json @@ -26,9 +33,10 @@ REDIS_URL= GROQ_API_KEY= GROQ_MODEL=llama-3.3-70b-versatile -# Fortexa auth (required) +# Fortexa auth (required in production) FORTEXA_AUTH_SECRET= # Wallet-only login allowlists (comma-separated Stellar public keys) +# At least one operator wallet should be configured in production to disable demo fallback. FORTEXA_OPERATOR_WALLETS= FORTEXA_VIEWER_WALLETS= FORTEXA_AUTH_MAX_ATTEMPTS=5 diff --git a/README.md b/README.md index eb0dcf2..76b18ff 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ To clean up local developer state safely, you can use the local demo reset utili ``` *(or `FORTEXA_ALLOW_LOCAL_RESET=true npx tsx scripts/reset-local-demo-state.ts --yes`)* +`.env.example` is intentionally development-oriented and uses Stellar testnet defaults, so it will not pass the production readiness check until you replace the demo values with production configuration. + --- ## 9) 🌍 Environment Variables @@ -316,6 +318,7 @@ npm run start npm run lint npm test npm run test:watch +npm run check:production-readiness npm run demo:scenarios npm run db:migrate ``` @@ -336,6 +339,48 @@ Run the standalone demo runner (prints expected vs actual for every seeded scena npm run demo:scenarios ``` +### Production Readiness Check + +Run this before every production deployment and before enabling protected payment flows: + +```bash +npm run check:production-readiness +``` + +The readiness check validates: + +- `STELLAR_HORIZON_URL` +- `STELLAR_NETWORK_PASSPHRASE` +- `FORTEXA_AUTH_SECRET` +- `FORTEXA_OPERATOR_WALLETS` +- `DATABASE_URL` or `FORTEXA_STORE_DIR` +- `REDIS_URL` or `FORTEXA_SHARED_STATE_PATH` + +It also rejects unsafe demo/default values such as testnet Horizon endpoints, mismatched Stellar network settings, and local demo file-store paths without printing secret values. + +Expected behavior: + +- Success: prints `Fortexa production readiness check passed.` and exits `0`. +- Failure: prints `Fortexa production readiness check failed:` followed by the invalid setting and remediation for each issue, then exits non-zero. + +Example success: + +```bash +$ npm run check:production-readiness +Fortexa production readiness check passed. +``` + +Example failure: + +```bash +$ npm run check:production-readiness +Fortexa production readiness check failed: +- STELLAR_NETWORK_PASSPHRASE: Testnet passphrase is still configured. Set STELLAR_NETWORK_PASSPHRASE to the Stellar public network passphrase before deployment. +- DATABASE_URL or FORTEXA_STORE_DIR: No persistent storage backend is explicitly configured. Configure DATABASE_URL for Postgres or set FORTEXA_STORE_DIR to a durable production storage path. +``` + +In `NODE_ENV=production`, Fortexa also applies this check before `/api/stellar/build-payment` and `/api/stellar/submit-signed` execute. If configuration is unsafe, those routes return `503` with a non-sensitive issue list and the remediation command instead of attempting the payment flow. + --- ## 11) 🔌 API Surface (Reference) diff --git a/package.json b/package.json index 4dc0300..01637a3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "check:production-readiness": "tsx scripts/check-production-readiness.ts", "demo:scenarios": "tsx scripts/demo-scenarios.ts", "db:migrate": "tsx scripts/run-db-migrations.ts", "demo:reset": "tsx scripts/reset-local-demo-state.ts", diff --git a/scripts/check-production-readiness.ts b/scripts/check-production-readiness.ts new file mode 100644 index 0000000..689e01e --- /dev/null +++ b/scripts/check-production-readiness.ts @@ -0,0 +1,18 @@ +import { loadEnvConfig } from "@next/env"; + +import { + checkProductionReadiness, + formatProductionReadinessReport, +} from "../src/lib/readiness/production"; + +loadEnvConfig(process.cwd()); + +const report = checkProductionReadiness(process.env); +const output = formatProductionReadinessReport(report); + +if (!report.ok) { + console.error(output); + process.exitCode = 1; +} else { + console.log(output); +} diff --git a/src/app/api/audit/export/route.test.ts b/src/app/api/audit/export/route.test.ts index b202fec..27b65ab 100644 --- a/src/app/api/audit/export/route.test.ts +++ b/src/app/api/audit/export/route.test.ts @@ -172,6 +172,7 @@ describe("/api/audit/export route", () => { }, } ); + const response = await GET(request); expect(response.status).toBe(200); diff --git a/src/app/api/stellar/build-payment/route.ts b/src/app/api/stellar/build-payment/route.ts index bcda278..7df26f6 100644 --- a/src/app/api/stellar/build-payment/route.ts +++ b/src/app/api/stellar/build-payment/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/auth/require-auth"; import { readJsonBody } from "@/lib/http/read-json-body"; +import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { buildUnsignedPaymentTransaction } from "@/lib/stellar/client"; import { verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote"; @@ -30,6 +31,19 @@ export async function POST(request: NextRequest) { return auth.response; } + const readinessReport = getProtectedPaymentFlowReadinessReport(); + if (readinessReport) { + return NextResponse.json( + { + error: + "Protected payment flows are disabled until Fortexa passes the production readiness check.", + issues: readinessReport.issues, + command: "npm run check:production-readiness", + }, + { status: 503, headers: rateLimitHeaders(rate) } + ); + } + const userId = auth.session.userId; const assignedWallet = await getUserWallet(userId); diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index fe967b7..e62239b 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -85,7 +85,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"); @@ -218,6 +217,13 @@ function viewerCookie() { describe("POST /api/stellar/submit-signed authorization", () => { it("returns 401 when unauthenticated", async () => { + vi.mocked(requireAuth).mockReturnValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Authentication required." }), { + status: 401, + }), + } as ReturnType); + const request = new NextRequest("http://localhost/api/stellar/submit-signed", { method: "POST", headers: { "content-type": "application/json" }, @@ -229,6 +235,13 @@ describe("POST /api/stellar/submit-signed authorization", () => { }); it("returns 403 for viewer role (operator-only route)", async () => { + vi.mocked(requireAuth).mockReturnValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Insufficient role." }), { + status: 403, + }), + } as ReturnType); + const request = new NextRequest("http://localhost/api/stellar/submit-signed", { method: "POST", headers: { @@ -241,4 +254,4 @@ describe("POST /api/stellar/submit-signed authorization", () => { const response = await POST(request); expect(response.status).toBe(403); }); -}); \ No newline at end of file +}); diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 7c42fba..7af13ee 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -5,8 +5,10 @@ import { readJsonBody } from "@/lib/http/read-json-body"; import { jsonWithRequestContext } from "@/lib/observability/http"; import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observability/logger"; import { recordStellarSubmitResult } from "@/lib/observability/metrics"; +import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr } from "@/lib/stellar/client"; +import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; import { getIdempotencyRecord, hashSignedXdr, @@ -48,10 +50,6 @@ const HORIZON_OP_ERRORS: Record = { }, }; -function getTestnetExplorerUrl(hash: string) { - return `https://stellar.expert/explorer/testnet/tx/${hash}`; -} - export function formatSubmitError(error: unknown) { if (!(error instanceof Error)) { return { message: "Failed to submit signed transaction." }; @@ -134,6 +132,23 @@ export async function POST(request: NextRequest) { return auth.response; } + const readinessReport = getProtectedPaymentFlowReadinessReport(); + if (readinessReport) { + logWarn("Submit signed blocked by production readiness check", context); + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 503, + body: { + error: + "Protected payment flows are disabled until Fortexa passes the production readiness check.", + issues: readinessReport.issues, + command: "npm run check:production-readiness", + }, + headers: rateLimitHeaders(rate), + }); + } + const userId = auth.session.userId; const bodyResult = await readJsonBody(request); @@ -287,7 +302,7 @@ export async function POST(request: NextRequest) { mode: "real", ...submitted, }, - explorerUrl: getTestnetExplorerUrl(submitted.hash), + explorerUrl: getStellarExplorerTransactionUrl(submitted.hash), }; if (idempotencyKey && xdrHash) { @@ -329,4 +344,4 @@ export async function POST(request: NextRequest) { headers: rateLimitHeaders(rate), }); } -} \ No newline at end of file +} diff --git a/src/lib/readiness/production.test.ts b/src/lib/readiness/production.test.ts new file mode 100644 index 0000000..83c2b69 --- /dev/null +++ b/src/lib/readiness/production.test.ts @@ -0,0 +1,145 @@ +import { Networks } from "@stellar/stellar-sdk"; +import { describe, expect, it } from "vitest"; + +import { + checkProductionReadiness, + formatProductionReadinessReport, + getProtectedPaymentFlowReadinessReport, +} from "@/lib/readiness/production"; + +const VALID_OPERATOR_WALLET = + "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + +describe("production readiness", () => { + it("passes for a valid production configuration", () => { + const report = checkProductionReadiness( + { + DATABASE_URL: "postgres://fortexa:secret@db.example.com:5432/fortexa", + FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", + FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, + FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", + STELLAR_HORIZON_URL: "https://horizon.stellar.org", + STELLAR_NETWORK_PASSPHRASE: Networks.PUBLIC, + }, + { cwd: "/srv/fortexa" } + ); + + expect(report.ok).toBe(true); + expect(report.issues).toEqual([]); + }); + + it("reports missing required production variables", () => { + const report = checkProductionReadiness({}, { cwd: "/srv/fortexa" }); + + expect(report.ok).toBe(false); + expect(report.issues.map((issue) => issue.setting)).toEqual( + expect.arrayContaining([ + "STELLAR_HORIZON_URL", + "STELLAR_NETWORK_PASSPHRASE", + "FORTEXA_AUTH_SECRET", + "FORTEXA_OPERATOR_WALLETS", + "DATABASE_URL or FORTEXA_STORE_DIR", + "REDIS_URL or FORTEXA_SHARED_STATE_PATH", + ]) + ); + }); + + it("rejects the wrong Stellar network passphrase for production", () => { + const report = checkProductionReadiness( + { + DATABASE_URL: "postgres://fortexa:secret@db.example.com:5432/fortexa", + FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", + FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, + FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", + STELLAR_HORIZON_URL: "https://horizon.stellar.org", + STELLAR_NETWORK_PASSPHRASE: Networks.TESTNET, + }, + { cwd: "/srv/fortexa" } + ); + + expect(report.ok).toBe(false); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + setting: "STELLAR_NETWORK_PASSPHRASE", + }), + ]) + ); + }); + + it("rejects missing storage backend configuration", () => { + const report = checkProductionReadiness( + { + FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", + FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, + FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", + STELLAR_HORIZON_URL: "https://horizon.stellar.org", + STELLAR_NETWORK_PASSPHRASE: Networks.PUBLIC, + }, + { cwd: "/srv/fortexa" } + ); + + expect(report.ok).toBe(false); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + setting: "DATABASE_URL or FORTEXA_STORE_DIR", + message: expect.stringContaining("persistent storage backend"), + }), + ]) + ); + }); + + it("rejects unsafe demo defaults for Horizon and file storage", () => { + const report = checkProductionReadiness( + { + FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", + FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, + FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", + FORTEXA_STORE_DIR: ".fortexa", + STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org", + STELLAR_NETWORK_PASSPHRASE: Networks.PUBLIC, + }, + { cwd: "/srv/fortexa" } + ); + + expect(report.ok).toBe(false); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + setting: "STELLAR_HORIZON_URL", + message: expect.stringContaining("Testnet Horizon"), + }), + expect.objectContaining({ + setting: "FORTEXA_STORE_DIR", + message: expect.stringContaining("demo default"), + }), + expect.objectContaining({ + setting: "STELLAR_HORIZON_URL, STELLAR_NETWORK_PASSPHRASE", + }), + ]) + ); + }); + + it("formats actionable output without exposing secret values", () => { + const report = checkProductionReadiness( + { + FORTEXA_AUTH_SECRET: "too-short-secret", + }, + { cwd: "/srv/fortexa" } + ); + + const formatted = formatProductionReadinessReport(report); + + expect(formatted).toContain("FORTEXA_AUTH_SECRET"); + expect(formatted).not.toContain("too-short-secret"); + }); + + it("only enforces payment-flow readiness in production", () => { + const report = getProtectedPaymentFlowReadinessReport({ + NODE_ENV: "development", + }); + + expect(report).toBeNull(); + }); +}); diff --git a/src/lib/readiness/production.ts b/src/lib/readiness/production.ts new file mode 100644 index 0000000..05fa880 --- /dev/null +++ b/src/lib/readiness/production.ts @@ -0,0 +1,254 @@ +import path from "node:path"; + +import { + STELLAR_PUBLIC_NETWORK_PASSPHRASE, + STELLAR_TESTNET_NETWORK_PASSPHRASE, + getStellarHorizonUrl, + inferStellarNetworkFromHorizonUrl, +} from "@/lib/stellar/network"; + +const STELLAR_PUBLIC_KEY = /^G[A-Z2-7]{55}$/u; +const MIN_AUTH_SECRET_LENGTH = 32; + +export type ProductionReadinessIssue = { + setting: string; + message: string; + remediation: string; +}; + +export type ProductionReadinessReport = { + ok: boolean; + issues: ProductionReadinessIssue[]; +}; + +type ProductionReadinessOptions = { + cwd?: string; +}; + +function normalizeConfiguredValue(value: string | undefined) { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function normalizeConfiguredPath( + configuredPath: string, + cwd: string +) { + return path.normalize( + path.isAbsolute(configuredPath) + ? configuredPath + : path.join(cwd, configuredPath) + ); +} + +function isValidHttpsUrl(value: string) { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +function countWallets(value: string | undefined) { + if (!value?.trim()) { + return 0; + } + + return value + .split(",") + .map((item) => item.trim().toUpperCase()) + .filter((item) => STELLAR_PUBLIC_KEY.test(item)).length; +} + +function isUnsafeFileStoreDefault( + configuredStoreDir: string, + cwd: string +) { + const resolved = normalizeConfiguredPath(configuredStoreDir, cwd); + const unsafeDefaults = [ + path.normalize(path.join(cwd, ".fortexa")), + path.normalize(path.join("/tmp", "fortexa")), + ]; + + return unsafeDefaults.includes(resolved); +} + +function addIssue( + issues: ProductionReadinessIssue[], + setting: string, + message: string, + remediation: string +) { + issues.push({ setting, message, remediation }); +} + +export function checkProductionReadiness( + env: NodeJS.ProcessEnv = process.env, + options: ProductionReadinessOptions = {} +): ProductionReadinessReport { + const cwd = options.cwd ?? process.cwd(); + const issues: ProductionReadinessIssue[] = []; + + const horizonUrl = normalizeConfiguredValue(env.STELLAR_HORIZON_URL); + if (!horizonUrl) { + addIssue( + issues, + "STELLAR_HORIZON_URL", + "Stellar Horizon URL is missing.", + "Set STELLAR_HORIZON_URL to your production Horizon endpoint." + ); + } else if (!isValidHttpsUrl(horizonUrl)) { + addIssue( + issues, + "STELLAR_HORIZON_URL", + "Stellar Horizon URL must be a valid HTTPS URL.", + "Update STELLAR_HORIZON_URL to an HTTPS Horizon endpoint reachable from production." + ); + } + + const networkPassphrase = normalizeConfiguredValue( + env.STELLAR_NETWORK_PASSPHRASE + ); + if (!networkPassphrase) { + addIssue( + issues, + "STELLAR_NETWORK_PASSPHRASE", + "Stellar network passphrase is missing.", + "Set STELLAR_NETWORK_PASSPHRASE to the Stellar public network passphrase for production." + ); + } else if (networkPassphrase !== STELLAR_PUBLIC_NETWORK_PASSPHRASE) { + const reason = + networkPassphrase === STELLAR_TESTNET_NETWORK_PASSPHRASE + ? "Testnet passphrase is still configured." + : "Configured passphrase does not match the Stellar public network."; + addIssue( + issues, + "STELLAR_NETWORK_PASSPHRASE", + reason, + "Set STELLAR_NETWORK_PASSPHRASE to the Stellar public network passphrase before deployment." + ); + } + + if (horizonUrl) { + const inferredNetwork = inferStellarNetworkFromHorizonUrl( + getStellarHorizonUrl({ ...env, STELLAR_HORIZON_URL: horizonUrl }) + ); + + if (inferredNetwork === "testnet") { + addIssue( + issues, + "STELLAR_HORIZON_URL", + "Testnet Horizon is an unsafe production default.", + "Point STELLAR_HORIZON_URL at a public-network Horizon service for production payments." + ); + } + + if ( + networkPassphrase === STELLAR_PUBLIC_NETWORK_PASSPHRASE && + inferredNetwork === "testnet" + ) { + addIssue( + issues, + "STELLAR_HORIZON_URL, STELLAR_NETWORK_PASSPHRASE", + "Horizon URL and network passphrase target different Stellar networks.", + "Use a public-network Horizon URL together with the public network passphrase." + ); + } + } + + const authSecret = normalizeConfiguredValue(env.FORTEXA_AUTH_SECRET); + if (!authSecret) { + addIssue( + issues, + "FORTEXA_AUTH_SECRET", + "Auth signing secret is missing.", + "Set FORTEXA_AUTH_SECRET to a strong random value of at least 32 characters." + ); + } else if (authSecret.length < MIN_AUTH_SECRET_LENGTH) { + addIssue( + issues, + "FORTEXA_AUTH_SECRET", + "Auth signing secret is too short for production use.", + "Rotate FORTEXA_AUTH_SECRET to a strong random value of at least 32 characters." + ); + } + + if (countWallets(env.FORTEXA_OPERATOR_WALLETS) === 0) { + addIssue( + issues, + "FORTEXA_OPERATOR_WALLETS", + "Operator wallet allowlist is empty, which leaves the demo operator fallback active.", + "Set FORTEXA_OPERATOR_WALLETS to one or more production operator Stellar public keys." + ); + } + + const databaseUrl = normalizeConfiguredValue(env.DATABASE_URL); + const storeDir = normalizeConfiguredValue(env.FORTEXA_STORE_DIR); + if (!databaseUrl && !storeDir) { + addIssue( + issues, + "DATABASE_URL or FORTEXA_STORE_DIR", + "No persistent storage backend is explicitly configured.", + "Configure DATABASE_URL for Postgres or set FORTEXA_STORE_DIR to a durable production storage path." + ); + } else if (storeDir && isUnsafeFileStoreDefault(storeDir, cwd)) { + addIssue( + issues, + "FORTEXA_STORE_DIR", + "File storage points at a local demo default path.", + "Set FORTEXA_STORE_DIR to a durable production path, or prefer DATABASE_URL for managed persistence." + ); + } + + const redisUrl = normalizeConfiguredValue(env.REDIS_URL); + const sharedStatePath = normalizeConfiguredValue( + env.FORTEXA_SHARED_STATE_PATH + ); + if (!redisUrl && !sharedStatePath) { + addIssue( + issues, + "REDIS_URL or FORTEXA_SHARED_STATE_PATH", + "Shared lockout and rate-limit state is not configured.", + "Configure REDIS_URL for multi-instance deployments or FORTEXA_SHARED_STATE_PATH for a shared file-backed state store." + ); + } + + return { + ok: issues.length === 0, + issues, + }; +} + +export function shouldEnforceProductionReadiness( + env: NodeJS.ProcessEnv = process.env +) { + return env.NODE_ENV === "production"; +} + +export function getProtectedPaymentFlowReadinessReport( + env: NodeJS.ProcessEnv = process.env, + options: ProductionReadinessOptions = {} +) { + if (!shouldEnforceProductionReadiness(env)) { + return null; + } + + const report = checkProductionReadiness(env, options); + return report.ok ? null : report; +} + +export function formatProductionReadinessReport( + report: ProductionReadinessReport +) { + if (report.ok) { + return "Fortexa production readiness check passed."; + } + + const lines = ["Fortexa production readiness check failed:"]; + + for (const issue of report.issues) { + lines.push(`- ${issue.setting}: ${issue.message} ${issue.remediation}`); + } + + return lines.join("\n"); +} diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index 92e692e..47d8154 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -4,7 +4,10 @@ import type { SecurityEvaluation, SecurityFinding, } from "@/lib/types/domain"; -import { fetchBlocklist } from "@/lib/security/blocklist"; +import { + fetchBlocklist, + getBlocklistHealth, +} from "@/lib/security/blocklist"; /** Configuration for analyzer timeout behavior. */ export interface AnalyzerConfig { @@ -97,7 +100,7 @@ function outputSafetyCheck(outputPreview?: string): SecurityFinding[] { } } - if (/private key|secret seed|mnemonic/i.test(outputPreview)) { + if (/private key|secret key|secret seed|mnemonic/i.test(outputPreview)) { findings.push({ code: "SECRET_TARGETING", title: "Sensitive secret extraction attempt", @@ -171,14 +174,23 @@ async function fetchBlocklistWithTimeout( try { const blocklist = await fetchBlocklist(); clearTimeout(timeoutId); + const health = getBlocklistHealth(); + if (health.lastError) { + return { + blocklist, + status: { + blocked: true, + timedOut: false, + error: health.lastError, + }, + }; + } 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"); return { blocklist: [], diff --git a/src/lib/stellar/network.ts b/src/lib/stellar/network.ts new file mode 100644 index 0000000..509f49b --- /dev/null +++ b/src/lib/stellar/network.ts @@ -0,0 +1,64 @@ +import { Networks } from "@stellar/stellar-sdk"; + +export const STELLAR_PUBLIC_NETWORK_PASSPHRASE = Networks.PUBLIC; +export const STELLAR_TESTNET_NETWORK_PASSPHRASE = Networks.TESTNET; + +function normalizeEnvValue(value: string | undefined) { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +export function getStellarNetworkPassphrase( + env: NodeJS.ProcessEnv = process.env +) { + return ( + normalizeEnvValue(env.STELLAR_NETWORK_PASSPHRASE) ?? + STELLAR_TESTNET_NETWORK_PASSPHRASE + ); +} + +export function getStellarHorizonUrl( + env: NodeJS.ProcessEnv = process.env +) { + return ( + normalizeEnvValue(env.STELLAR_HORIZON_URL) ?? + "https://horizon-testnet.stellar.org" + ); +} + +export function inferStellarNetworkFromHorizonUrl(url: string): + | "public" + | "testnet" + | "unknown" { + try { + const parsed = new URL(url); + const normalized = `${parsed.hostname}${parsed.pathname}`.toLowerCase(); + + if (normalized.includes("testnet")) { + return "testnet"; + } + + if ( + normalized.includes("horizon.stellar.org") || + normalized.includes("mainnet") + ) { + return "public"; + } + + return "unknown"; + } catch { + return "unknown"; + } +} + +export function getStellarExplorerTransactionUrl( + hash: string, + passphrase = getStellarNetworkPassphrase() +) { + const networkSegment = + passphrase === STELLAR_PUBLIC_NETWORK_PASSPHRASE + ? "public" + : "testnet"; + + return `https://stellar.expert/explorer/${networkSegment}/tx/${hash}`; +}