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:
parent
c41ea252b0
commit
67abc6a01b
@ -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(
|
||||
|
||||
124
src/security/secret-guard.ts
Normal file
124
src/security/secret-guard.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user