diff --git a/README.md b/README.md index 0273187..b00c9a7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Here is the list of all utilities: - [SQL Minifer](https://jam.dev/utilities/sql-minifier) - [Internet Speed Test](https://jam.dev/utilities/internet-speed-test) - [Random String Generator](https://jam.dev/utilities/random-string-generator) +- [Config Doctor](https://jam.dev/utilities/config-doctor) ### Built With diff --git a/components/config-doctor/AIExplainer.tsx b/components/config-doctor/AIExplainer.tsx new file mode 100644 index 0000000..fb0ec82 --- /dev/null +++ b/components/config-doctor/AIExplainer.tsx @@ -0,0 +1,325 @@ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { EnvVariable } from "@/components/utils/config-doctor.utils"; +import { Button } from "@/components/ds/ButtonComponent"; + +interface AIExplainerProps { + variable: EnvVariable; +} + +type LoadingState = "idle" | "loading-model" | "generating" | "done" | "error"; + +// WebLLM types +interface ChatCompletionMessageParam { + role: "system" | "user" | "assistant"; + content: string; +} + +interface MLCEngine { + reload: (model: string) => Promise; + chat: { + completions: { + create: (params: { + messages: ChatCompletionMessageParam[]; + max_tokens?: number; + temperature?: number; + }) => Promise<{ choices: Array<{ message: { content: string } }> }>; + }; + }; +} + +interface InitProgressReport { + progress: number; + text: string; +} + +// Global engine instance to reuse across components +let globalEngine: MLCEngine | null = null; +let engineLoadPromise: Promise | null = null; + +const MODEL_ID = "Phi-3.5-mini-instruct-q4f16_1-MLC"; + +async function getOrCreateEngine( + onProgress?: (report: InitProgressReport) => void +): Promise { + if (globalEngine) { + return globalEngine; + } + + if (engineLoadPromise) { + return engineLoadPromise; + } + + engineLoadPromise = (async () => { + // Dynamically import WebLLM + const webllm = await import("@mlc-ai/web-llm"); + const engine = await webllm.CreateMLCEngine(MODEL_ID, { + initProgressCallback: onProgress, + }); + globalEngine = engine; + return engine; + })(); + + return engineLoadPromise; +} + +function buildPrompt(variable: EnvVariable): ChatCompletionMessageParam[] { + return [ + { + role: "system", + content: `You are a security expert reviewing deployment configuration for a web application. Be concise and helpful. Respond in 2-3 short sentences.`, + }, + { + role: "user", + content: `I have an environment variable in my .env file: +Key: ${variable.key} +Detected as: ${variable.secretType.replace(/_/g, " ")} +Risk level: ${variable.riskLevel} + +Explain briefly: +1. Why this might be sensitive +2. What could happen if it's exposed +3. How to handle it securely`, + }, + ]; +} + +export default function AIExplainer({ variable }: AIExplainerProps) { + const [state, setState] = useState("idle"); + const [progress, setProgress] = useState(0); + const [progressText, setProgressText] = useState(""); + const [explanation, setExplanation] = useState(null); + const [error, setError] = useState(null); + const [isWebGPUSupported, setIsWebGPUSupported] = useState( + null + ); + const abortRef = useRef(false); + + // Check WebGPU support on mount + useEffect(() => { + const checkWebGPU = async () => { + try { + if (!navigator.gpu) { + setIsWebGPUSupported(false); + return; + } + const adapter = await navigator.gpu.requestAdapter(); + setIsWebGPUSupported(adapter !== null); + } catch { + setIsWebGPUSupported(false); + } + }; + checkWebGPU(); + }, []); + + const handleExplain = useCallback(async () => { + if (state === "loading-model" || state === "generating") { + return; + } + + abortRef.current = false; + setError(null); + setExplanation(null); + + try { + setState("loading-model"); + setProgress(0); + setProgressText("Initializing AI model..."); + + const engine = await getOrCreateEngine((report) => { + if (abortRef.current) return; + setProgress(Math.round(report.progress * 100)); + setProgressText(report.text); + }); + + if (abortRef.current) return; + + setState("generating"); + setProgressText("Analyzing security implications..."); + + const messages = buildPrompt(variable); + const response = await engine.chat.completions.create({ + messages, + max_tokens: 256, + temperature: 0.7, + }); + + if (abortRef.current) return; + + const content = response.choices[0]?.message?.content; + if (content) { + setExplanation(content); + setState("done"); + } else { + throw new Error("No response from model"); + } + } catch (err) { + if (abortRef.current) return; + console.error("AI Explainer error:", err); + setError( + err instanceof Error ? err.message : "Failed to generate explanation" + ); + setState("error"); + } + }, [variable, state]); + + // Reset when variable changes + useEffect(() => { + setExplanation(null); + setError(null); + setState("idle"); + }, [variable.key]); + + // WebGPU not supported + if (isWebGPUSupported === false) { + return ( +
+

AI Explanation unavailable

+

+ Your browser doesn't support WebGPU, which is required for + client-side AI. Try Chrome 113+ or Edge 113+. +

+
+ ); + } + + // Still checking WebGPU + if (isWebGPUSupported === null) { + return null; + } + + return ( +
+ {state === "idle" && ( + + )} + + {(state === "loading-model" || state === "generating") && ( +
+
+ + + {state === "loading-model" + ? "Loading AI model..." + : "Generating explanation..."} + +
+ {state === "loading-model" && ( +
+
+
+
+

+ {progressText} +

+
+ )} + +
+ )} + + {state === "done" && explanation && ( +
+
+ + AI Security Analysis +
+

{explanation}

+ +
+ )} + + {state === "error" && ( +
+

+ Failed to generate explanation +

+ {error && ( +

{error}

+ )} + +
+ )} +
+ ); +} + +function SparklesIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function LoadingSpinner() { + return ( + + + + + ); +} diff --git a/components/config-doctor/PlatformSelector.tsx b/components/config-doctor/PlatformSelector.tsx new file mode 100644 index 0000000..178f7c7 --- /dev/null +++ b/components/config-doctor/PlatformSelector.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Platform, PLATFORM_INFO } from "@/components/utils/config-doctor.utils"; +import { cn } from "@/lib/utils"; + +interface PlatformSelectorProps { + selected: Platform; + onSelect: (platform: Platform) => void; +} + +const platforms: Platform[] = ["netlify", "vercel", "cloudflare"]; + +export default function PlatformSelector({ + selected, + onSelect, +}: PlatformSelectorProps) { + return ( +
+ {platforms.map((platform) => { + const info = PLATFORM_INFO[platform]; + const isSelected = selected === platform; + + return ( + + ); + })} +
+ ); +} diff --git a/components/config-doctor/SecurityWarnings.tsx b/components/config-doctor/SecurityWarnings.tsx new file mode 100644 index 0000000..b55b95a --- /dev/null +++ b/components/config-doctor/SecurityWarnings.tsx @@ -0,0 +1,210 @@ +import React, { useState } from "react"; +import { + EnvVariable, + getSecuritySummary, +} from "@/components/utils/config-doctor.utils"; +import { cn } from "@/lib/utils"; +import AIExplainer from "./AIExplainer"; + +interface SecurityWarningsProps { + variables: EnvVariable[]; +} + +const RiskIcon = ({ level }: { level: EnvVariable["riskLevel"] }) => { + switch (level) { + case "danger": + return ( + + + + ); + case "warning": + return ( + + + + ); + case "safe": + return ( + + + + ); + } +}; + +const RiskBadge = ({ level }: { level: EnvVariable["riskLevel"] }) => { + const colors = { + danger: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + warning: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", + safe: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400", + }; + + const labels = { + danger: "Secret", + warning: "Review", + safe: "Safe", + }; + + return ( + + {labels[level]} + + ); +}; + +export default function SecurityWarnings({ variables }: SecurityWarningsProps) { + const [expandedVar, setExpandedVar] = useState(null); + const summary = getSecuritySummary(variables); + + if (variables.length === 0) { + return ( +
+ Paste your .env file above to see security analysis +
+ ); + } + + // Sort by risk level: danger first, then warning, then safe + const sortedVariables = [...variables].sort((a, b) => { + const order = { danger: 0, warning: 1, safe: 2 }; + return order[a.riskLevel] - order[b.riskLevel]; + }); + + return ( +
+ {/* Summary */} +
+
+ {summary.total} + variables +
+ {summary.danger > 0 && ( +
+ + {summary.danger} + secrets +
+ )} + {summary.warning > 0 && ( +
+ + {summary.warning} + to review +
+ )} + {summary.safe > 0 && ( +
+ + {summary.safe} + safe +
+ )} +
+ + {/* Variables list */} +
+ {sortedVariables.map((variable) => { + const isExpanded = expandedVar === variable.key; + + return ( +
+ + + {isExpanded && ( +
+
+ Type:{" "} + {variable.secretType.replace(/_/g, " ")} +
+ {variable.recommendation && ( +
+ Recommendation:{" "} + {variable.recommendation} +
+ )} + {variable.riskLevel !== "safe" && ( + + )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/components/seo/ConfigDoctorSEO.tsx b/components/seo/ConfigDoctorSEO.tsx new file mode 100644 index 0000000..e66b2e8 --- /dev/null +++ b/components/seo/ConfigDoctorSEO.tsx @@ -0,0 +1,188 @@ +export default function ConfigDoctorSEO() { + return ( +
+
+

What is Config Doctor?

+

+ Config Doctor is a free tool that converts your .env{" "} + environment files into platform-specific deployment configurations for{" "} + Netlify, Vercel, and{" "} + Cloudflare Pages. It also analyzes your environment + variables for security risks and provides AI-powered explanations for + sensitive configurations. +

+
+ +
+

Features

+
    +
  • + Multi-Platform Support: Convert to Netlify's{" "} + netlify.toml, Vercel's vercel.json, or + Cloudflare's wrangler.toml formats. +
  • +
  • + Security Analysis: Automatically detects API keys, + database credentials, authentication tokens, and other sensitive + data. +
  • +
  • + AI Explanations: Get detailed security + recommendations powered by on-device AI (no data sent to servers). +
  • +
  • + Smart Secret Handling: Secrets are flagged with + instructions for secure storage using each platform's secrets + management. +
  • +
+
+ +
+

How to Use

+
    +
  1. + Select your target platform: +

    + Choose Netlify, Vercel, or Cloudflare Pages depending on where + you're deploying. +

    +
  2. +
  3. + Paste your .env file: +

    + Copy the contents of your .env file and paste them into + the input area. Comments and empty lines are handled + automatically. +

    +
  4. +
  5. + Review security warnings: +

    + Check the security analysis panel to see which variables are + flagged as sensitive. Click on any variable for details and + AI-powered explanations. +

    +
  6. +
  7. + Copy the output: +

    + Copy the generated configuration and add it to your deployment + config file. Follow the instructions for storing secrets securely. +

    +
  8. +
+
+ +
+

Security Levels Explained

+
    +
  • + + Secrets (Red): + {" "} + Variables that should never be exposed in client-side code or + committed to version control. These include API keys, database URLs, + authentication tokens, and private keys. +
  • +
  • + + Review (Yellow): + {" "} + Variables that may or may not be sensitive. Review these to + determine if they contain confidential data. +
  • +
  • + + Safe (Green): + {" "} + Variables explicitly designed for client-side use, such as{" "} + NEXT_PUBLIC_*, VITE_*, or{" "} + REACT_APP_* prefixed variables. +
  • +
+
+ +
+

Supported Patterns

+

Config Doctor recognizes common environment variable patterns:

+
    +
  • + API Keys: *_API_KEY, *_SECRET,{" "} + OPENAI_*, STRIPE_* +
  • +
  • + Cloud Credentials: AWS_*,{" "} + AZURE_*, GCP_*, GOOGLE_* +
  • +
  • + Database URLs: DATABASE_URL,{" "} + MONGODB_*, REDIS_*, POSTGRES_* +
  • +
  • + Auth Tokens: JWT_SECRET,{" "} + *_TOKEN, SESSION_SECRET +
  • +
  • + Public Variables: NEXT_PUBLIC_*,{" "} + VITE_*, REACT_APP_*, NUXT_PUBLIC_* +
  • +
+
+ +
+

AI-Powered Analysis

+

+ The "Explain with AI" feature uses{" "} + + WebLLM + {" "} + to run a language model directly in your browser. This means: +

+
    +
  • + Privacy First: Your environment variables never + leave your device. All AI processing happens locally. +
  • +
  • + One-Time Download: The AI model is downloaded once + and cached in your browser for future use. +
  • +
  • + Requires WebGPU: Works best in Chrome 113+ or Edge + 113+ with WebGPU support. +
  • +
+
+ +
+

Platform-Specific Notes

+

Netlify

+

+ Variables are output in the{" "} + [context.production.environment] section of{" "} + netlify.toml. For sensitive values, use Netlify's + environment variable UI or CLI instead of committing them to the file. +

+ +

Vercel

+

+ Secrets are referenced using the @secret-name syntax in{" "} + vercel.json. You'll need to add the actual secret + values using vercel secrets add or the Vercel dashboard. +

+ +

Cloudflare Pages

+

+ Public variables go in the [vars] section of{" "} + wrangler.toml. Secrets should be added using{" "} + wrangler secret put or the Cloudflare dashboard. +

+
+
+ ); +} diff --git a/components/utils/config-doctor.utils.test.ts b/components/utils/config-doctor.utils.test.ts new file mode 100644 index 0000000..78f2d57 --- /dev/null +++ b/components/utils/config-doctor.utils.test.ts @@ -0,0 +1,460 @@ +import { + parseEnvFile, + analyzeSecurityRisk, + convertToNetlify, + convertToVercel, + convertToCloudflare, + convertToPlatform, + getSecuritySummary, + EnvVariable, +} from "./config-doctor.utils"; + +describe("parseEnvFile", () => { + it("should return an empty array for empty input", () => { + expect(parseEnvFile("")).toEqual([]); + expect(parseEnvFile(" ")).toEqual([]); + }); + + it("should parse a single environment variable", () => { + const result = parseEnvFile("KEY=value"); + expect(result).toHaveLength(1); + expect(result[0].key).toBe("KEY"); + expect(result[0].value).toBe("value"); + expect(result[0].line).toBe(1); + }); + + it("should parse multiple environment variables", () => { + const input = `KEY1=value1 +KEY2=value2 +KEY3=value3`; + const result = parseEnvFile(input); + expect(result).toHaveLength(3); + expect(result[0].key).toBe("KEY1"); + expect(result[1].key).toBe("KEY2"); + expect(result[2].key).toBe("KEY3"); + }); + + it("should ignore comments", () => { + const input = `# This is a comment +KEY=value +# Another comment`; + const result = parseEnvFile(input); + expect(result).toHaveLength(1); + expect(result[0].key).toBe("KEY"); + }); + + it("should ignore empty lines", () => { + const input = ` +KEY1=value1 + +KEY2=value2 + +`; + const result = parseEnvFile(input); + expect(result).toHaveLength(2); + }); + + it("should handle values with double quotes", () => { + const result = parseEnvFile('KEY="quoted value"'); + expect(result[0].value).toBe("quoted value"); + }); + + it("should handle values with single quotes", () => { + const result = parseEnvFile("KEY='quoted value'"); + expect(result[0].value).toBe("quoted value"); + }); + + it("should handle values with equals signs", () => { + const result = parseEnvFile("DATABASE_URL=postgres://user:pass@host/db?ssl=true"); + expect(result[0].key).toBe("DATABASE_URL"); + expect(result[0].value).toBe("postgres://user:pass@host/db?ssl=true"); + }); + + it("should track correct line numbers", () => { + const input = `# Comment +KEY1=value1 + +KEY2=value2`; + const result = parseEnvFile(input); + expect(result[0].line).toBe(2); + expect(result[1].line).toBe(4); + }); + + it("should handle empty values", () => { + const result = parseEnvFile("KEY="); + expect(result[0].key).toBe("KEY"); + expect(result[0].value).toBe(""); + }); +}); + +describe("analyzeSecurityRisk", () => { + describe("danger level - API keys", () => { + it("should detect API_KEY patterns", () => { + expect(analyzeSecurityRisk("API_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("STRIPE_API_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("MY_SERVICE_API_KEY").riskLevel).toBe("danger"); + }); + + it("should detect SECRET patterns", () => { + expect(analyzeSecurityRisk("SECRET").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("SECRET_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("APP_SECRET").riskLevel).toBe("danger"); + }); + + it("should detect OpenAI keys", () => { + expect(analyzeSecurityRisk("OPENAI_API_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("OPENAI_ORG_ID").riskLevel).toBe("danger"); + }); + + it("should detect Stripe keys", () => { + expect(analyzeSecurityRisk("STRIPE_SECRET_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("STRIPE_LIVE_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("STRIPE_TEST_KEY").riskLevel).toBe("danger"); + }); + }); + + describe("danger level - cloud credentials", () => { + it("should detect AWS credentials", () => { + expect(analyzeSecurityRisk("AWS_ACCESS_KEY_ID").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("AWS_SECRET_ACCESS_KEY").riskLevel).toBe("danger"); + }); + + it("should detect Azure credentials", () => { + expect(analyzeSecurityRisk("AZURE_CLIENT_ID").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("AZURE_CLIENT_SECRET").riskLevel).toBe("danger"); + }); + + it("should detect GCP credentials", () => { + expect(analyzeSecurityRisk("GCP_PROJECT_ID").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("GOOGLE_APPLICATION_CREDENTIALS").riskLevel).toBe("danger"); + }); + }); + + describe("danger level - database connections", () => { + it("should detect DATABASE_URL", () => { + expect(analyzeSecurityRisk("DATABASE_URL").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("DATABASE_URL").secretType).toBe("connection_string"); + }); + + it("should detect MongoDB URLs", () => { + expect(analyzeSecurityRisk("MONGODB_URI").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("MONGO_URL").riskLevel).toBe("danger"); + }); + + it("should detect Redis URLs", () => { + expect(analyzeSecurityRisk("REDIS_URL").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("REDISCLOUD_URL").riskLevel).toBe("danger"); + }); + + it("should detect SQL database URLs", () => { + expect(analyzeSecurityRisk("POSTGRES_URL").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("MYSQL_HOST").riskLevel).toBe("danger"); + }); + }); + + describe("danger level - auth tokens", () => { + it("should detect JWT_SECRET", () => { + expect(analyzeSecurityRisk("JWT_SECRET").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("JWT_SECRET").secretType).toBe("token"); + }); + + it("should detect TOKEN patterns", () => { + expect(analyzeSecurityRisk("ACCESS_TOKEN").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("REFRESH_TOKEN").riskLevel).toBe("danger"); + }); + + it("should detect AUTH patterns", () => { + expect(analyzeSecurityRisk("AUTH_SECRET").riskLevel).toBe("danger"); + }); + }); + + describe("danger level - private keys", () => { + it("should detect PRIVATE_KEY", () => { + expect(analyzeSecurityRisk("PRIVATE_KEY").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("PRIVATE_KEY").secretType).toBe("private_key"); + }); + + it("should detect private patterns", () => { + expect(analyzeSecurityRisk("RSA_PRIVATE").riskLevel).toBe("danger"); + expect(analyzeSecurityRisk("SSH_PRIVATE_KEY").riskLevel).toBe("danger"); + }); + }); + + describe("warning level", () => { + it("should detect PASSWORD patterns", () => { + expect(analyzeSecurityRisk("PASSWORD").riskLevel).toBe("warning"); + expect(analyzeSecurityRisk("DB_PASSWORD").riskLevel).toBe("warning"); + expect(analyzeSecurityRisk("USER_PASSWORD").riskLevel).toBe("warning"); + }); + + it("should return warning for unknown variables", () => { + expect(analyzeSecurityRisk("SOME_RANDOM_VAR").riskLevel).toBe("warning"); + expect(analyzeSecurityRisk("MY_CONFIG").riskLevel).toBe("warning"); + }); + }); + + describe("safe level - public variables", () => { + it("should detect NEXT_PUBLIC_ prefix", () => { + expect(analyzeSecurityRisk("NEXT_PUBLIC_API_URL").riskLevel).toBe("safe"); + expect(analyzeSecurityRisk("NEXT_PUBLIC_SITE_NAME").riskLevel).toBe("safe"); + }); + + it("should detect VITE_ prefix", () => { + expect(analyzeSecurityRisk("VITE_API_URL").riskLevel).toBe("safe"); + expect(analyzeSecurityRisk("VITE_PUBLIC_KEY").riskLevel).toBe("safe"); + }); + + it("should detect REACT_APP_ prefix", () => { + expect(analyzeSecurityRisk("REACT_APP_API_URL").riskLevel).toBe("safe"); + }); + + it("should detect NUXT_PUBLIC_ prefix", () => { + expect(analyzeSecurityRisk("NUXT_PUBLIC_API_URL").riskLevel).toBe("safe"); + }); + + it("should detect EXPO_PUBLIC_ prefix", () => { + expect(analyzeSecurityRisk("EXPO_PUBLIC_API_URL").riskLevel).toBe("safe"); + }); + }); +}); + +describe("convertToNetlify", () => { + it("should return empty string for empty array", () => { + expect(convertToNetlify([])).toBe(""); + }); + + it("should convert a single variable", () => { + const vars: EnvVariable[] = [ + { + key: "KEY", + value: "value", + line: 1, + isSecret: false, + secretType: "unknown", + riskLevel: "warning", + }, + ]; + const result = convertToNetlify(vars); + expect(result).toContain("[context.production.environment]"); + expect(result).toContain('KEY = "value"'); + }); + + it("should convert multiple variables", () => { + const vars: EnvVariable[] = [ + { + key: "KEY1", + value: "value1", + line: 1, + isSecret: false, + secretType: "unknown", + riskLevel: "warning", + }, + { + key: "KEY2", + value: "value2", + line: 2, + isSecret: false, + secretType: "unknown", + riskLevel: "warning", + }, + ]; + const result = convertToNetlify(vars); + expect(result).toContain('KEY1 = "value1"'); + expect(result).toContain('KEY2 = "value2"'); + }); + + it("should escape special characters in values", () => { + const vars: EnvVariable[] = [ + { + key: "KEY", + value: 'value with "quotes"', + line: 1, + isSecret: false, + secretType: "unknown", + riskLevel: "warning", + }, + ]; + const result = convertToNetlify(vars); + expect(result).toContain('\\"quotes\\"'); + }); +}); + +describe("convertToVercel", () => { + it("should return empty object for empty array", () => { + expect(convertToVercel([])).toBe("{}"); + }); + + it("should convert variables to JSON format", () => { + const vars: EnvVariable[] = [ + { + key: "PUBLIC_KEY", + value: "value", + line: 1, + isSecret: false, + secretType: "public", + riskLevel: "safe", + }, + ]; + const result = convertToVercel(vars); + const parsed = JSON.parse(result); + expect(parsed.env.PUBLIC_KEY).toBe("value"); + }); + + it("should use @ prefix for secrets", () => { + const vars: EnvVariable[] = [ + { + key: "DATABASE_URL", + value: "postgres://...", + line: 1, + isSecret: true, + secretType: "connection_string", + riskLevel: "danger", + }, + ]; + const result = convertToVercel(vars); + expect(result).toContain("@database-url"); + expect(result).toContain("vercel secrets add database-url"); + }); +}); + +describe("convertToCloudflare", () => { + it("should return empty string for empty array", () => { + expect(convertToCloudflare([])).toBe(""); + }); + + it("should separate public vars and secrets", () => { + const vars: EnvVariable[] = [ + { + key: "NEXT_PUBLIC_URL", + value: "https://example.com", + line: 1, + isSecret: false, + secretType: "public", + riskLevel: "safe", + }, + { + key: "DATABASE_URL", + value: "postgres://...", + line: 2, + isSecret: true, + secretType: "connection_string", + riskLevel: "danger", + }, + ]; + const result = convertToCloudflare(vars); + expect(result).toContain("[vars]"); + expect(result).toContain('NEXT_PUBLIC_URL = "https://example.com"'); + expect(result).toContain("wrangler secret put DATABASE_URL"); + }); + + it("should only show secrets section if there are secrets", () => { + const vars: EnvVariable[] = [ + { + key: "DATABASE_URL", + value: "postgres://...", + line: 1, + isSecret: true, + secretType: "connection_string", + riskLevel: "danger", + }, + ]; + const result = convertToCloudflare(vars); + expect(result).not.toContain("[vars]"); + expect(result).toContain("wrangler secret put DATABASE_URL"); + }); +}); + +describe("convertToPlatform", () => { + const testVars: EnvVariable[] = [ + { + key: "KEY", + value: "value", + line: 1, + isSecret: false, + secretType: "unknown", + riskLevel: "warning", + }, + ]; + + it("should convert to Netlify format", () => { + const result = convertToPlatform(testVars, "netlify"); + expect(result).toContain("[context.production.environment]"); + }); + + it("should convert to Vercel format", () => { + const result = convertToPlatform(testVars, "vercel"); + expect(result).toContain('"env"'); + }); + + it("should convert to Cloudflare format", () => { + const result = convertToPlatform(testVars, "cloudflare"); + expect(result).toContain("[vars]"); + }); + + it("should throw for unknown platform", () => { + expect(() => convertToPlatform(testVars, "unknown" as any)).toThrow(); + }); +}); + +describe("getSecuritySummary", () => { + it("should return correct counts", () => { + const vars: EnvVariable[] = [ + { key: "NEXT_PUBLIC_URL", value: "", line: 1, isSecret: false, secretType: "public", riskLevel: "safe" }, + { key: "SOME_VAR", value: "", line: 2, isSecret: false, secretType: "unknown", riskLevel: "warning" }, + { key: "ANOTHER_VAR", value: "", line: 3, isSecret: false, secretType: "unknown", riskLevel: "warning" }, + { key: "API_KEY", value: "", line: 4, isSecret: true, secretType: "api_key", riskLevel: "danger" }, + { key: "DATABASE_URL", value: "", line: 5, isSecret: true, secretType: "connection_string", riskLevel: "danger" }, + ]; + + const summary = getSecuritySummary(vars); + expect(summary.total).toBe(5); + expect(summary.safe).toBe(1); + expect(summary.warning).toBe(2); + expect(summary.danger).toBe(2); + }); + + it("should return zeros for empty array", () => { + const summary = getSecuritySummary([]); + expect(summary.total).toBe(0); + expect(summary.safe).toBe(0); + expect(summary.warning).toBe(0); + expect(summary.danger).toBe(0); + }); +}); + +describe("integration: parseEnvFile with security analysis", () => { + it("should correctly analyze a typical .env file", () => { + const input = `# Database +DATABASE_URL=postgres://user:pass@localhost/db + +# API Keys +STRIPE_SECRET_KEY=sk_live_xxx +OPENAI_API_KEY=sk-xxx + +# Public +NEXT_PUBLIC_API_URL=https://api.example.com + +# General +NODE_ENV=production`; + + const result = parseEnvFile(input); + + expect(result).toHaveLength(5); + + const dbUrl = result.find(v => v.key === "DATABASE_URL"); + expect(dbUrl?.riskLevel).toBe("danger"); + expect(dbUrl?.secretType).toBe("connection_string"); + + const stripeKey = result.find(v => v.key === "STRIPE_SECRET_KEY"); + expect(stripeKey?.riskLevel).toBe("danger"); + + const openaiKey = result.find(v => v.key === "OPENAI_API_KEY"); + expect(openaiKey?.riskLevel).toBe("danger"); + + const publicUrl = result.find(v => v.key === "NEXT_PUBLIC_API_URL"); + expect(publicUrl?.riskLevel).toBe("safe"); + + const nodeEnv = result.find(v => v.key === "NODE_ENV"); + expect(nodeEnv?.riskLevel).toBe("warning"); + }); +}); diff --git a/components/utils/config-doctor.utils.ts b/components/utils/config-doctor.utils.ts new file mode 100644 index 0000000..ce3f1c1 --- /dev/null +++ b/components/utils/config-doctor.utils.ts @@ -0,0 +1,478 @@ +/** + * Config Doctor Utility Functions + * Parses .env files, analyzes security risks, and converts to platform-specific formats. + */ + +// Types +export type Platform = "netlify" | "vercel" | "cloudflare"; +export type RiskLevel = "safe" | "warning" | "danger"; +export type SecretType = + | "api_key" + | "password" + | "token" + | "private_key" + | "connection_string" + | "cloud_credential" + | "public" + | "unknown"; + +export interface EnvVariable { + key: string; + value: string; + line: number; + isSecret: boolean; + secretType: SecretType; + riskLevel: RiskLevel; + recommendation?: string; +} + +export interface SecurityPattern { + pattern: RegExp; + type: SecretType; + risk: RiskLevel; + recommendation: string; +} + +// Security patterns for detecting sensitive environment variables +export const SECRET_PATTERNS: SecurityPattern[] = [ + // Danger - API Keys and Secrets + { + pattern: /^(.*_)?API_KEY$/i, + type: "api_key", + risk: "danger", + recommendation: "Store as a secret environment variable, never commit to git", + }, + { + pattern: /^(.*_)?SECRET(_KEY)?$/i, + type: "api_key", + risk: "danger", + recommendation: "Store as a secret environment variable, never commit to git", + }, + { + pattern: /^(.*_)?API_SECRET$/i, + type: "api_key", + risk: "danger", + recommendation: "Store as a secret environment variable, never commit to git", + }, + { + pattern: /^OPENAI_/i, + type: "api_key", + risk: "danger", + recommendation: + "OpenAI API keys can incur significant costs if exposed. Rotate immediately if compromised.", + }, + { + pattern: /^STRIPE_(SECRET|LIVE|TEST)_/i, + type: "api_key", + risk: "danger", + recommendation: + "Stripe secret keys allow full access to your account. Use restricted keys when possible.", + }, + { + pattern: /^STRIPE_.*_KEY$/i, + type: "api_key", + risk: "danger", + recommendation: + "Stripe keys should be stored securely. Publishable keys are safe for client-side use.", + }, + + // Danger - Cloud Credentials + { + pattern: /^AWS_/i, + type: "cloud_credential", + risk: "danger", + recommendation: + "AWS credentials can access your entire cloud infrastructure. Use IAM roles instead when possible.", + }, + { + pattern: /^AZURE_/i, + type: "cloud_credential", + risk: "danger", + recommendation: + "Azure credentials should use managed identities in production.", + }, + { + pattern: /^GCP_/i, + type: "cloud_credential", + risk: "danger", + recommendation: + "GCP credentials should use service accounts with minimal permissions.", + }, + { + pattern: /^GOOGLE_/i, + type: "cloud_credential", + risk: "danger", + recommendation: "Google credentials should be stored as secrets.", + }, + + // Danger - Database and Connection Strings + { + pattern: /^DATABASE_URL$/i, + type: "connection_string", + risk: "danger", + recommendation: + "Database URLs contain credentials. Never expose in client-side code.", + }, + { + pattern: /^(MONGODB|MONGO)_/i, + type: "connection_string", + risk: "danger", + recommendation: + "MongoDB connection strings contain credentials. Use environment secrets.", + }, + { + pattern: /^(REDIS|REDISCLOUD)_/i, + type: "connection_string", + risk: "danger", + recommendation: "Redis URLs may contain authentication. Store securely.", + }, + { + pattern: /^(POSTGRES|PG|MYSQL|MARIA)_/i, + type: "connection_string", + risk: "danger", + recommendation: + "Database credentials should be stored as secrets, not in version control.", + }, + { + pattern: /^SUPABASE_/i, + type: "connection_string", + risk: "danger", + recommendation: + "Supabase keys should be stored securely. Anon keys are safe for client-side.", + }, + + // Danger - Auth and Tokens + { + pattern: /^JWT_SECRET$/i, + type: "token", + risk: "danger", + recommendation: + "JWT secrets allow forging authentication tokens. Keep strictly confidential.", + }, + { + pattern: /^(.*_)?TOKEN$/i, + type: "token", + risk: "danger", + recommendation: + "Tokens should be stored as secrets and rotated regularly.", + }, + { + pattern: /^AUTH_/i, + type: "token", + risk: "danger", + recommendation: + "Authentication secrets should never be exposed in client-side code.", + }, + { + pattern: /^SESSION_SECRET$/i, + type: "token", + risk: "danger", + recommendation: + "Session secrets allow session hijacking if exposed. Keep confidential.", + }, + { + pattern: /^ENCRYPTION_KEY$/i, + type: "token", + risk: "danger", + recommendation: + "Encryption keys should be stored in a secrets manager, never in code.", + }, + + // Danger - Private Keys + { + pattern: /^PRIVATE_KEY$/i, + type: "private_key", + risk: "danger", + recommendation: + "Private keys should be stored in a secrets manager or HSM.", + }, + { + pattern: /^(.*_)?PRIVATE(_KEY)?$/i, + type: "private_key", + risk: "danger", + recommendation: + "Private keys grant access to encrypted communications. Protect carefully.", + }, + + // Warning - Passwords + { + pattern: /^(.*_)?PASSWORD$/i, + type: "password", + risk: "warning", + recommendation: + "Passwords should be stored as secrets, not in configuration files.", + }, + + // Safe - Public keys (designed to be exposed) + { + pattern: /^NEXT_PUBLIC_/i, + type: "public", + risk: "safe", + recommendation: "Safe to expose in client-side code by design.", + }, + { + pattern: /^VITE_/i, + type: "public", + risk: "safe", + recommendation: "Vite public variables are designed for client-side use.", + }, + { + pattern: /^REACT_APP_/i, + type: "public", + risk: "safe", + recommendation: + "Create React App public variables are designed for client-side use.", + }, + { + pattern: /^NUXT_PUBLIC_/i, + type: "public", + risk: "safe", + recommendation: "Nuxt public variables are designed for client-side use.", + }, + { + pattern: /^EXPO_PUBLIC_/i, + type: "public", + risk: "safe", + recommendation: "Expo public variables are designed for client-side use.", + }, +]; + +/** + * Analyze a single environment variable key for security risks + */ +export function analyzeSecurityRisk(key: string): { + isSecret: boolean; + secretType: SecretType; + riskLevel: RiskLevel; + recommendation: string; +} { + for (const pattern of SECRET_PATTERNS) { + if (pattern.pattern.test(key)) { + return { + isSecret: pattern.risk !== "safe", + secretType: pattern.type, + riskLevel: pattern.risk, + recommendation: pattern.recommendation, + }; + } + } + + // Default: unknown variables get a warning + return { + isSecret: false, + secretType: "unknown", + riskLevel: "warning", + recommendation: + "Review if this variable contains sensitive data before exposing.", + }; +} + +/** + * Parse a .env file content into structured EnvVariable objects + */ +export function parseEnvFile(content: string): EnvVariable[] { + if (!content.trim()) { + return []; + } + + const lines = content.split("\n"); + const variables: EnvVariable[] = []; + + lines.forEach((line, index) => { + const trimmedLine = line.trim(); + + // Skip empty lines and comments + if (trimmedLine === "" || trimmedLine.startsWith("#")) { + return; + } + + // Parse KEY=VALUE format + const equalIndex = trimmedLine.indexOf("="); + if (equalIndex === -1) { + return; // Invalid line, skip + } + + const key = trimmedLine.substring(0, equalIndex).trim(); + let value = trimmedLine.substring(equalIndex + 1).trim(); + + // Remove surrounding quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // Analyze security risk + const securityAnalysis = analyzeSecurityRisk(key); + + variables.push({ + key, + value, + line: index + 1, + ...securityAnalysis, + }); + }); + + return variables; +} + +/** + * Convert environment variables to Netlify TOML format + */ +export function convertToNetlify(vars: EnvVariable[]): string { + if (vars.length === 0) { + return ""; + } + + const envLines = vars + .map((v) => ` ${v.key} = "${escapeTomlValue(v.value)}"`) + .join("\n"); + + return `[context.production.environment]\n${envLines}`; +} + +/** + * Convert environment variables to Vercel JSON format + */ +export function convertToVercel(vars: EnvVariable[]): string { + if (vars.length === 0) { + return "{}"; + } + + const envObject: Record = {}; + const secretsComments: string[] = []; + + vars.forEach((v) => { + if (v.riskLevel === "danger") { + // For secrets, suggest using Vercel's secret reference format + const secretName = v.key.toLowerCase().replace(/_/g, "-"); + envObject[v.key] = `@${secretName}`; + secretsComments.push( + `# ${v.key}: Add secret via: vercel secrets add ${secretName} "your-value"` + ); + } else { + envObject[v.key] = v.value; + } + }); + + const jsonOutput = JSON.stringify({ env: envObject }, null, 2); + + if (secretsComments.length > 0) { + return `${secretsComments.join("\n")}\n\n${jsonOutput}`; + } + + return jsonOutput; +} + +/** + * Convert environment variables to Cloudflare Pages wrangler.toml format + */ +export function convertToCloudflare(vars: EnvVariable[]): string { + if (vars.length === 0) { + return ""; + } + + const publicVars: EnvVariable[] = []; + const secretVars: EnvVariable[] = []; + + vars.forEach((v) => { + if (v.riskLevel === "danger") { + secretVars.push(v); + } else { + publicVars.push(v); + } + }); + + let output = ""; + + if (publicVars.length > 0) { + output += "[vars]\n"; + publicVars.forEach((v) => { + output += `${v.key} = "${escapeTomlValue(v.value)}"\n`; + }); + } + + if (secretVars.length > 0) { + output += "\n# Secrets - set via Cloudflare dashboard or wrangler CLI:\n"; + secretVars.forEach((v) => { + output += `# wrangler secret put ${v.key}\n`; + }); + } + + return output.trim(); +} + +/** + * Convert environment variables to the specified platform format + */ +export function convertToPlatform( + vars: EnvVariable[], + platform: Platform +): string { + switch (platform) { + case "netlify": + return convertToNetlify(vars); + case "vercel": + return convertToVercel(vars); + case "cloudflare": + return convertToCloudflare(vars); + default: + throw new Error(`Unknown platform: ${platform}`); + } +} + +/** + * Escape special characters for TOML values + */ +function escapeTomlValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +/** + * Get summary statistics for security analysis + */ +export function getSecuritySummary(vars: EnvVariable[]): { + total: number; + safe: number; + warning: number; + danger: number; +} { + return { + total: vars.length, + safe: vars.filter((v) => v.riskLevel === "safe").length, + warning: vars.filter((v) => v.riskLevel === "warning").length, + danger: vars.filter((v) => v.riskLevel === "danger").length, + }; +} + +/** + * Platform display information + */ +export const PLATFORM_INFO: Record< + Platform, + { name: string; configFile: string; docsUrl: string } +> = { + netlify: { + name: "Netlify", + configFile: "netlify.toml", + docsUrl: "https://docs.netlify.com/environment-variables/overview/", + }, + vercel: { + name: "Vercel", + configFile: "vercel.json", + docsUrl: + "https://vercel.com/docs/projects/environment-variables", + }, + cloudflare: { + name: "Cloudflare Pages", + configFile: "wrangler.toml", + docsUrl: + "https://developers.cloudflare.com/pages/functions/bindings/#environment-variables", + }, +}; diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index aa8ef64..45076c5 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -179,4 +179,10 @@ export const tools = [ "Transform XML data into JSON format instantly. Simplifies working with APIs and modern web applications that prefer JSON.", link: "/utilities/xml-to-json", }, + { + title: "Config Doctor", + description: + "Convert .env files to Netlify, Vercel, or Cloudflare Pages configs with AI-powered security analysis. Detect secrets and deploy safely.", + link: "/utilities/config-doctor", + }, ]; diff --git a/package.json b/package.json index a6b44bd..69d1095 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format:check": "prettier --check '**/*.{js,jsx,ts,tsx,json,css,scss,md}'" }, "dependencies": { + "@mlc-ai/web-llm": "^0.2.78", "@cloudflare/speedtest": "^1.6.0", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-checkbox": "^1.1.1", diff --git a/pages/utilities/config-doctor.tsx b/pages/utilities/config-doctor.tsx new file mode 100644 index 0000000..2fdfc0e --- /dev/null +++ b/pages/utilities/config-doctor.tsx @@ -0,0 +1,188 @@ +import { useCallback, useState, useMemo } from "react"; +import { Textarea } from "@/components/ds/TextareaComponent"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Button } from "@/components/ds/ButtonComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import { useCopyToClipboard } from "@/components/hooks/useCopyToClipboard"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import PlatformSelector from "@/components/config-doctor/PlatformSelector"; +import SecurityWarnings from "@/components/config-doctor/SecurityWarnings"; +import ConfigDoctorSEO from "@/components/seo/ConfigDoctorSEO"; +import { + Platform, + parseEnvFile, + convertToPlatform, + PLATFORM_INFO, +} from "@/components/utils/config-doctor.utils"; + +export default function ConfigDoctor() { + const [input, setInput] = useState(""); + const [platform, setPlatform] = useState("netlify"); + const { buttonText, handleCopy } = useCopyToClipboard(); + + // Parse env variables with security analysis + const variables = useMemo(() => { + if (!input.trim()) { + return []; + } + try { + return parseEnvFile(input); + } catch { + return []; + } + }, [input]); + + // Convert to selected platform format + const output = useMemo(() => { + if (variables.length === 0) { + return ""; + } + try { + return convertToPlatform(variables, platform); + } catch { + return "Error converting configuration"; + } + }, [variables, platform]); + + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + setInput(event.currentTarget.value); + }, + [] + ); + + const handleClear = useCallback(() => { + setInput(""); + }, []); + + const platformInfo = PLATFORM_INFO[platform]; + + return ( +
+ +
+ + +
+ +
+ +
+ +
+ {/* Platform Selection */} +
+ + +

+ Output format:{" "} + + {platformInfo.configFile} + +

+
+ + {/* Input */} +
+ +