security(fix): extract hardcoded secrets to .env
When running `clawdbot security audit --fix`, detect hardcoded secrets
in the config and:
1. Extract them to ~/.clawdbot/.env with secure permissions (0600)
2. Replace config values with ${ENV_VAR} references
3. Display which secrets were extracted
Uses existing redact patterns from logging/redact.ts plus high-entropy
fallback for unknown formats.
Closes #2320
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f5c90f0e5c
commit
1d1b794969
@ -105,6 +105,20 @@ export function registerSecurityCli(program: Command) {
|
|||||||
else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
|
else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
|
||||||
else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
|
else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
|
||||||
}
|
}
|
||||||
|
if (fixResult.secretsExtracted && fixResult.secretsExtracted.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
muted(
|
||||||
|
` Extracted ${fixResult.secretsExtracted.length} secret(s) to ${shortenHomePath(fixResult.envPath ?? "")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const s of fixResult.secretsExtracted) {
|
||||||
|
lines.push(muted(` ${s.path.join(".")} → \${${s.envVar}}`));
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(muted(" ⚠️ Add .env to your backup exclusions"));
|
||||||
|
lines.push(muted(" ⚠️ Restart gateway: clawdbot gateway restart"));
|
||||||
|
}
|
||||||
if (fixResult.errors.length > 0) {
|
if (fixResult.errors.length > 0) {
|
||||||
for (const err of fixResult.errors) {
|
for (const err of fixResult.errors) {
|
||||||
lines.push(muted(` error: ${shortenHomeInString(err)}`));
|
lines.push(muted(` error: ${shortenHomeInString(err)}`));
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
|
|||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { runExec } from "../process/exec.js";
|
import { runExec } from "../process/exec.js";
|
||||||
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
|
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
|
||||||
|
import { getDefaultRedactPatterns } from "../logging/redact.js";
|
||||||
|
|
||||||
export type SecurityFixChmodAction = {
|
export type SecurityFixChmodAction = {
|
||||||
kind: "chmod";
|
kind: "chmod";
|
||||||
@ -33,6 +34,12 @@ export type SecurityFixIcaclsAction = {
|
|||||||
|
|
||||||
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
|
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
|
||||||
|
|
||||||
|
export type ExtractedSecret = {
|
||||||
|
path: string[];
|
||||||
|
value: string;
|
||||||
|
envVar: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SecurityFixResult = {
|
export type SecurityFixResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
@ -41,6 +48,8 @@ export type SecurityFixResult = {
|
|||||||
changes: string[];
|
changes: string[];
|
||||||
actions: SecurityFixAction[];
|
actions: SecurityFixAction[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
secretsExtracted?: ExtractedSecret[];
|
||||||
|
envPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function safeChmod(params: {
|
async function safeChmod(params: {
|
||||||
@ -281,6 +290,165 @@ function applyConfigFixes(params: { cfg: ClawdbotConfig; env: NodeJS.ProcessEnv
|
|||||||
return { cfg: next, changes, policyFlips };
|
return { cfg: next, changes, policyFlips };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secret extraction helpers
|
||||||
|
function looksLikeEnvRef(value: string): boolean {
|
||||||
|
const v = value.trim();
|
||||||
|
return v.startsWith("${") && v.endsWith("}");
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSecret(value: string): boolean {
|
||||||
|
if (typeof value !== "string" || value.length < 16) return false;
|
||||||
|
if (looksLikeEnvRef(value)) return false;
|
||||||
|
|
||||||
|
// Compile redact patterns and test against value
|
||||||
|
const patterns = getDefaultRedactPatterns();
|
||||||
|
for (const p of patterns) {
|
||||||
|
try {
|
||||||
|
const re = new RegExp(p, "i");
|
||||||
|
if (re.test(value)) return true;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-entropy fallback for unknown formats
|
||||||
|
if (value.length >= 32 && /^[A-Za-z0-9_-]+$/.test(value)) {
|
||||||
|
const entropy = new Set(value).size / value.length;
|
||||||
|
if (entropy > 0.4) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToEnvVarName(configPath: string[]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const segment of configPath) {
|
||||||
|
// Skip generic container names
|
||||||
|
if (
|
||||||
|
["channels", "accounts", "providers", "entries", "list", "env", "docker", "sandbox"].includes(
|
||||||
|
segment,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Normalize known field names
|
||||||
|
if (segment === "token") parts.push("TOKEN");
|
||||||
|
else if (segment === "botToken") parts.push("BOT_TOKEN");
|
||||||
|
else if (segment === "apiKey") parts.push("API_KEY");
|
||||||
|
else if (segment === "password") parts.push("PASSWORD");
|
||||||
|
else if (segment === "controlToken") parts.push("CONTROL_TOKEN");
|
||||||
|
else parts.push(segment.toUpperCase().replace(/-/g, "_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parts.join("_");
|
||||||
|
return name.startsWith("CLAWDBOT_") ? name : `CLAWDBOT_${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSecrets(obj: unknown, currentPath: string[] = []): ExtractedSecret[] {
|
||||||
|
const secrets: ExtractedSecret[] = [];
|
||||||
|
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
if (looksLikeSecret(obj)) {
|
||||||
|
secrets.push({
|
||||||
|
path: currentPath,
|
||||||
|
value: obj,
|
||||||
|
envVar: pathToEnvVarName(currentPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
for (let i = 0; i < obj.length; i++) {
|
||||||
|
secrets.push(...findSecrets(obj[i], [...currentPath, String(i)]));
|
||||||
|
}
|
||||||
|
} else if (obj && typeof obj === "object") {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
secrets.push(...findSecrets(value, [...currentPath, key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValueAtPath(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
configPath: string[],
|
||||||
|
newValue: string,
|
||||||
|
): void {
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (let i = 0; i < configPath.length - 1; i++) {
|
||||||
|
const key = configPath[i];
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
current = current[Number(key)];
|
||||||
|
} else if (current && typeof current === "object") {
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalKey = configPath[configPath.length - 1];
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
current[Number(finalKey)] = newValue;
|
||||||
|
} else if (current && typeof current === "object") {
|
||||||
|
(current as Record<string, unknown>)[finalKey] = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSecretsFromConfig(cfg: ClawdbotConfig): {
|
||||||
|
updatedConfig: ClawdbotConfig;
|
||||||
|
secrets: ExtractedSecret[];
|
||||||
|
} {
|
||||||
|
const secrets = findSecrets(cfg);
|
||||||
|
|
||||||
|
// Deduplicate env var names (add suffix if needed)
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
for (const secret of secrets) {
|
||||||
|
let name = secret.envVar;
|
||||||
|
let counter = 1;
|
||||||
|
while (usedNames.has(name)) {
|
||||||
|
name = `${secret.envVar}_${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
secret.envVar = name;
|
||||||
|
usedNames.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone config and replace values with env var references
|
||||||
|
const updatedConfig = structuredClone(cfg);
|
||||||
|
for (const secret of secrets) {
|
||||||
|
setValueAtPath(updatedConfig as Record<string, unknown>, secret.path, `\${${secret.envVar}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updatedConfig, secrets };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeEnvFile(params: {
|
||||||
|
envPath: string;
|
||||||
|
secrets: ExtractedSecret[];
|
||||||
|
append?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const lines = [
|
||||||
|
"# Clawdbot Secrets - DO NOT COMMIT",
|
||||||
|
`# Generated by clawdbot security audit --fix`,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const secret of params.secrets) {
|
||||||
|
lines.push(`${secret.envVar}=${secret.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = lines.join("\n") + "\n";
|
||||||
|
|
||||||
|
if (params.append) {
|
||||||
|
await fs.appendFile(params.envPath, "\n" + content);
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(params.envPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secure permissions
|
||||||
|
await fs.chmod(params.envPath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
function listDirectIncludes(parsed: unknown): string[] {
|
function listDirectIncludes(parsed: unknown): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const visit = (value: unknown) => {
|
const visit = (value: unknown) => {
|
||||||
@ -429,6 +597,9 @@ export async function fixSecurityFootguns(opts?: {
|
|||||||
|
|
||||||
let configWritten = false;
|
let configWritten = false;
|
||||||
let changes: string[] = [];
|
let changes: string[] = [];
|
||||||
|
let secretsExtracted: ExtractedSecret[] | undefined;
|
||||||
|
let envPath: string | undefined;
|
||||||
|
|
||||||
if (snap.valid) {
|
if (snap.valid) {
|
||||||
const fixed = applyConfigFixes({ cfg: snap.config, env });
|
const fixed = applyConfigFixes({ cfg: snap.config, env });
|
||||||
changes = fixed.changes;
|
changes = fixed.changes;
|
||||||
@ -443,6 +614,23 @@ export async function fixSecurityFootguns(opts?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract secrets to .env
|
||||||
|
const extraction = extractSecretsFromConfig(fixed.cfg);
|
||||||
|
if (extraction.secrets.length > 0) {
|
||||||
|
secretsExtracted = extraction.secrets;
|
||||||
|
envPath = path.join(stateDir, ".env");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeEnvFile({ envPath, secrets: extraction.secrets });
|
||||||
|
changes.push(`extracted ${extraction.secrets.length} secret(s) to ${envPath}`);
|
||||||
|
|
||||||
|
// Use the updated config with env var references
|
||||||
|
Object.assign(fixed.cfg, extraction.updatedConfig);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`writeEnvFile failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
try {
|
try {
|
||||||
await io.writeConfigFile(fixed.cfg);
|
await io.writeConfigFile(fixed.cfg);
|
||||||
@ -490,5 +678,7 @@ export async function fixSecurityFootguns(opts?: {
|
|||||||
changes,
|
changes,
|
||||||
actions,
|
actions,
|
||||||
errors,
|
errors,
|
||||||
|
secretsExtracted,
|
||||||
|
envPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user