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.
This commit is contained in:
SpencersServer 2026-01-29 13:13:00 +02:00
parent c41ea252b0
commit 67abc6a01b
2 changed files with 139 additions and 0 deletions

View File

@ -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(

View File

@ -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<string, unknown> {
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."
);
}
}