From 67abc6a01b9d0ebe0c03651ec91387c318e45f34 Mon Sep 17 00:00:00 2001 From: SpencersServer Date: Thu, 29 Jan 2026 13:13:00 +0200 Subject: [PATCH] security: add secret-guard to enforce env-only secrets - Add src/security/secret-guard.ts module that: - Scans config objects and JSON files for plaintext secrets - Enforces chmod 600 on sensitive files (oauth.json, auth-profiles.json) - Refuses to start if secrets are detected in files - Hook secret-guard into config loading (src/config/io.ts) - Checks config after validation but before defaults are applied - Checks oauth.json and auth-profiles.json on startup This is step 1 of the security hardening initiative. Secrets must now come from environment variables only. --- src/config/io.ts | 15 +++++ src/security/secret-guard.ts | 124 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/security/secret-guard.ts diff --git a/src/config/io.ts b/src/config/io.ts index 50f1edb82..66635f657 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -33,6 +33,12 @@ import { applyConfigOverrides } from "./runtime-overrides.js"; import type { MoltbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; import { compareMoltbotVersions } from "./version.js"; +import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; +import { resolveOAuthPath } from "./paths.js"; +import { + assertNoSecretsInConfig, + assertNoSecretsInFile, +} from "../security/secret-guard.js"; // Re-export for backwards compatibility export { CircularIncludeError, ConfigIncludeError } from "./includes.js"; @@ -254,6 +260,15 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { .join("\n"); deps.logger.warn(`Config warnings:\\n${details}`); } + + // SECURITY: enforce env-only secrets and fail if plaintext secrets are detected. + assertNoSecretsInConfig(resolvedConfig); + assertNoSecretsInFile( + resolveOAuthPath(deps.env, resolveStateDir(deps.env, deps.homedir)), + "oauth.json" + ); + assertNoSecretsInFile(resolveAuthStorePath(), "auth-profiles.json"); + warnIfConfigFromFuture(validated.config, deps.logger); const cfg = applyModelDefaults( applyCompactionDefaults( diff --git a/src/security/secret-guard.ts b/src/security/secret-guard.ts new file mode 100644 index 000000000..4c558e4d7 --- /dev/null +++ b/src/security/secret-guard.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * SECURITY: Secret Guard Module + * + * Enforces that secrets must come from environment variables only. + * Refuses to start if plaintext secrets are detected in config files. + * + * Migration note: Remove all secrets from files and use env vars instead. + */ + +const SECRET_KEYS = new Set([ + "apikey", + "api_key", + "token", + "access", + "refresh", + "password", + "secret", + "clientsecret", + "client_secret", + "key", + "bearer", +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isSecretKey(key: string): boolean { + const normalized = String(key).trim().toLowerCase(); + return SECRET_KEYS.has(normalized); +} + +interface SecretMatch { + path: string; + key: string; +} + +function scanForSecrets( + value: unknown, + pathParts: string[] = [] +): SecretMatch | null { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i += 1) { + const match = scanForSecrets(value[i], [...pathParts, String(i)]); + if (match) return match; + } + return null; + } + + if (!isRecord(value)) return null; + + for (const [key, entry] of Object.entries(value)) { + if (isSecretKey(key)) { + if (typeof entry === "string" && entry.trim()) { + return { path: [...pathParts, key].join("."), key }; + } + } + const nested = scanForSecrets(entry, [...pathParts, key]); + if (nested) return nested; + } + + return null; +} + +/** + * Ensures a file has 600 permissions (owner read/write only). + * Best-effort: does not throw on chmod failures. + */ +export function ensureFilePermissions600(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.chmodSync(filePath, 0o600); + } + } catch { + // best-effort; do not throw on chmod failures + } +} + +/** + * Asserts that a JSON file does not contain plaintext secrets. + * Throws an error if secrets are detected, refusing to start. + * Also enforces 600 permissions on the file. + */ +export function assertNoSecretsInFile(filePath: string, label?: string): void { + if (!fs.existsSync(filePath)) return; + + ensureFilePermissions600(filePath); + + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw); + const hit = scanForSecrets(parsed); + + if (hit) { + const source = label ?? path.basename(filePath); + throw new Error( + `Refusing to start: ${source} contains plaintext secrets at ${hit.path}. ` + + "Move secrets to environment variables and remove them from files." + ); + } + } catch (err) { + if (err instanceof Error && err.message.includes("Refusing to start")) { + throw err; + } + // If file is unreadable or invalid JSON, ignore here (handled elsewhere). + } +} + +/** + * Asserts that a config object does not contain plaintext secrets. + * Throws an error if secrets are detected, refusing to start. + */ +export function assertNoSecretsInConfig(cfg: unknown): void { + const hit = scanForSecrets(cfg); + if (hit) { + throw new Error( + `Refusing to start: config contains plaintext secret at ${hit.path}. ` + + "Secrets must be supplied via environment variables only." + ); + } +}