feat(security): add audit --fix

This commit is contained in:
Peter Steinberger
2026-01-15 04:49:37 +00:00
parent 0a7f5bf6a5
commit edd8c613d6
6 changed files with 483 additions and 6 deletions

235
src/security/fix.ts Normal file
View File

@@ -0,0 +1,235 @@
import fs from "node:fs/promises";
import type { ClawdbotConfig } from "../config/config.js";
import { createConfigIO } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
export type SecurityFixChmodAction = {
kind: "chmod";
path: string;
mode: number;
ok: boolean;
skipped?: string;
error?: string;
};
export type SecurityFixResult = {
ok: boolean;
stateDir: string;
configPath: string;
configWritten: boolean;
changes: string[];
actions: SecurityFixChmodAction[];
errors: string[];
};
async function safeChmod(params: {
path: string;
mode: number;
require: "dir" | "file";
}): Promise<SecurityFixChmodAction> {
try {
const st = await fs.lstat(params.path);
if (st.isSymbolicLink()) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "symlink",
};
}
if (params.require === "dir" && !st.isDirectory()) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "not-a-directory",
};
}
if (params.require === "file" && !st.isFile()) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "not-a-file",
};
}
const current = st.mode & 0o777;
if (current === params.mode) {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "already",
};
}
await fs.chmod(params.path, params.mode);
return { kind: "chmod", path: params.path, mode: params.mode, ok: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
skipped: "missing",
};
}
return {
kind: "chmod",
path: params.path,
mode: params.mode,
ok: false,
error: String(err),
};
}
}
function setGroupPolicyAllowlist(params: {
cfg: ClawdbotConfig;
channel: string;
changes: string[];
policyFlips: Set<string>;
}): void {
if (!params.cfg.channels) return;
const section = params.cfg.channels[params.channel as keyof ClawdbotConfig["channels"]] as
| Record<string, unknown>
| undefined;
if (!section || typeof section !== "object") return;
const topPolicy = section.groupPolicy;
if (topPolicy === "open") {
section.groupPolicy = "allowlist";
params.changes.push(`channels.${params.channel}.groupPolicy=open -> allowlist`);
params.policyFlips.add(`channels.${params.channel}.`);
}
const accounts = section.accounts;
if (!accounts || typeof accounts !== "object") return;
for (const [accountId, accountValue] of Object.entries(accounts)) {
if (!accountId) continue;
if (!accountValue || typeof accountValue !== "object") continue;
const account = accountValue as Record<string, unknown>;
if (account.groupPolicy === "open") {
account.groupPolicy = "allowlist";
params.changes.push(
`channels.${params.channel}.accounts.${accountId}.groupPolicy=open -> allowlist`,
);
params.policyFlips.add(`channels.${params.channel}.accounts.${accountId}.`);
}
}
}
function setWhatsAppGroupAllowFromFromStore(params: {
cfg: ClawdbotConfig;
storeAllowFrom: string[];
changes: string[];
policyFlips: Set<string>;
}): void {
const section = params.cfg.channels?.whatsapp as Record<string, unknown> | undefined;
if (!section || typeof section !== "object") return;
if (params.storeAllowFrom.length === 0) return;
const maybeApply = (prefix: string, obj: Record<string, unknown>) => {
if (!params.policyFlips.has(prefix)) return;
const allowFrom = Array.isArray(obj.allowFrom) ? obj.allowFrom : [];
const groupAllowFrom = Array.isArray(obj.groupAllowFrom) ? obj.groupAllowFrom : [];
if (allowFrom.length > 0) return;
if (groupAllowFrom.length > 0) return;
obj.groupAllowFrom = params.storeAllowFrom;
params.changes.push(`${prefix}groupAllowFrom=pairing-store`);
};
maybeApply("channels.whatsapp.", section);
const accounts = section.accounts;
if (!accounts || typeof accounts !== "object") return;
for (const [accountId, accountValue] of Object.entries(accounts)) {
if (!accountValue || typeof accountValue !== "object") continue;
const account = accountValue as Record<string, unknown>;
maybeApply(`channels.whatsapp.accounts.${accountId}.`, account);
}
}
function applyConfigFixes(params: {
cfg: ClawdbotConfig;
env: NodeJS.ProcessEnv;
}): { cfg: ClawdbotConfig; changes: string[]; policyFlips: Set<string> } {
const next = structuredClone(params.cfg ?? {});
const changes: string[] = [];
const policyFlips = new Set<string>();
if (next.logging?.redactSensitive === "off") {
next.logging = { ...next.logging, redactSensitive: "tools" };
changes.push('logging.redactSensitive=off -> "tools"');
}
for (const channel of ["telegram", "whatsapp", "discord", "signal", "imessage", "slack", "msteams"]) {
setGroupPolicyAllowlist({ cfg: next, channel, changes, policyFlips });
}
return { cfg: next, changes, policyFlips };
}
export async function fixSecurityFootguns(opts?: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
configPath?: string;
}): Promise<SecurityFixResult> {
const env = opts?.env ?? process.env;
const stateDir = opts?.stateDir ?? resolveStateDir(env);
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
const actions: SecurityFixChmodAction[] = [];
const errors: string[] = [];
const io = createConfigIO({ env, configPath });
const snap = await io.readConfigFileSnapshot();
if (!snap.valid) {
errors.push(...snap.issues.map((i) => `${i.path}: ${i.message}`));
}
let configWritten = false;
let changes: string[] = [];
if (snap.valid) {
const fixed = applyConfigFixes({ cfg: snap.config, env });
changes = fixed.changes;
const whatsappStoreAllowFrom = await readChannelAllowFromStore("whatsapp", env).catch(() => []);
if (whatsappStoreAllowFrom.length > 0) {
setWhatsAppGroupAllowFromFromStore({
cfg: fixed.cfg,
storeAllowFrom: whatsappStoreAllowFrom,
changes,
policyFlips: fixed.policyFlips,
});
}
if (changes.length > 0) {
try {
await io.writeConfigFile(fixed.cfg);
configWritten = true;
} catch (err) {
errors.push(`writeConfigFile failed: ${String(err)}`);
}
}
}
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
return {
ok: errors.length === 0,
stateDir,
configPath,
configWritten,
changes,
actions,
errors,
};
}