fix: use Windows ACLs for security audit

This commit is contained in:
Peter Steinberger
2026-01-26 18:19:58 +00:00
parent b9098f3401
commit ab73aceb27
8 changed files with 760 additions and 110 deletions

View File

@@ -34,6 +34,7 @@ Status: unreleased.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. - Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. - TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.

View File

@@ -87,16 +87,23 @@ export function registerSecurityCli(program: Command) {
lines.push(muted(` ${shortenHomeInString(change)}`)); lines.push(muted(` ${shortenHomeInString(change)}`));
} }
for (const action of fixResult.actions) { for (const action of fixResult.actions) {
const mode = action.mode.toString(8).padStart(3, "0"); if (action.kind === "chmod") {
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`)); const mode = action.mode.toString(8).padStart(3, "0");
else if (action.skipped) if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
lines.push( else if (action.skipped)
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`), lines.push(
); muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
else if (action.error) );
lines.push( else if (action.error)
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`), lines.push(
); muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
);
continue;
}
const command = shortenHomeInString(action.command);
if (action.ok) lines.push(muted(` ${command}`));
else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
} }
if (fixResult.errors.length > 0) { if (fixResult.errors.length > 0) {
for (const err of fixResult.errors) { for (const err of fixResult.errors) {

View File

@@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js"; import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { import {
formatOctal, formatPermissionDetail,
isGroupReadable, formatPermissionRemediation,
isGroupWritable, inspectPathPermissions,
isWorldReadable,
isWorldWritable,
modeBits,
safeStat, safeStat,
} from "./audit-fs.js"; } from "./audit-fs.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditFinding = { export type SecurityAuditFinding = {
checkId: string; checkId: string;
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
export async function collectIncludeFilePermFindings(params: { export async function collectIncludeFilePermFindings(params: {
configSnapshot: ConfigFileSnapshot; configSnapshot: ConfigFileSnapshot;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> { }): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = []; const findings: SecurityAuditFinding[] = [];
if (!params.configSnapshot.exists) return findings; if (!params.configSnapshot.exists) return findings;
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
for (const p of includePaths) { for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const st = await safeStat(p); const perms = await inspectPathPermissions(p, {
if (!st.ok) continue; env: params.env,
const bits = modeBits(st.mode); platform: params.platform,
if (isWorldWritable(bits) || isGroupWritable(bits)) { exec: params.execIcacls,
});
if (!perms.ok) continue;
if (perms.worldWritable || perms.groupWritable) {
findings.push({ findings.push({
checkId: "fs.config_include.perms_writable", checkId: "fs.config_include.perms_writable",
severity: "critical", severity: "critical",
title: "Config include file is writable by others", title: "Config include file is writable by others",
detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`, detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
remediation: `chmod 600 ${p}`, remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} else if (isWorldReadable(bits)) { } else if (perms.worldReadable) {
findings.push({ findings.push({
checkId: "fs.config_include.perms_world_readable", checkId: "fs.config_include.perms_world_readable",
severity: "critical", severity: "critical",
title: "Config include file is world-readable", title: "Config include file is world-readable",
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`, detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: `chmod 600 ${p}`, remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} else if (isGroupReadable(bits)) { } else if (perms.groupReadable) {
findings.push({ findings.push({
checkId: "fs.config_include.perms_group_readable", checkId: "fs.config_include.perms_group_readable",
severity: "warn", severity: "warn",
title: "Config include file is group-readable", title: "Config include file is group-readable",
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`, detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: `chmod 600 ${p}`, remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} }
} }
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
stateDir: string; stateDir: string;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> { }): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = []; const findings: SecurityAuditFinding[] = [];
const oauthDir = resolveOAuthDir(params.env, params.stateDir); const oauthDir = resolveOAuthDir(params.env, params.stateDir);
const oauthStat = await safeStat(oauthDir); const oauthPerms = await inspectPathPermissions(oauthDir, {
if (oauthStat.ok && oauthStat.isDir) { env: params.env,
const bits = modeBits(oauthStat.mode); platform: params.platform,
if (isWorldWritable(bits) || isGroupWritable(bits)) { exec: params.execIcacls,
});
if (oauthPerms.ok && oauthPerms.isDir) {
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
findings.push({ findings.push({
checkId: "fs.credentials_dir.perms_writable", checkId: "fs.credentials_dir.perms_writable",
severity: "critical", severity: "critical",
title: "Credentials dir is writable by others", title: "Credentials dir is writable by others",
detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`, detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
remediation: `chmod 700 ${oauthDir}`, remediation: formatPermissionRemediation({
targetPath: oauthDir,
perms: oauthPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
}); });
} else if (isGroupReadable(bits) || isWorldReadable(bits)) { } else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
findings.push({ findings.push({
checkId: "fs.credentials_dir.perms_readable", checkId: "fs.credentials_dir.perms_readable",
severity: "warn", severity: "warn",
title: "Credentials dir is readable by others", title: "Credentials dir is readable by others",
detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`, detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
remediation: `chmod 700 ${oauthDir}`, remediation: formatPermissionRemediation({
targetPath: oauthDir,
perms: oauthPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
}); });
} }
} }
@@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: {
const agentDir = path.join(params.stateDir, "agents", agentId, "agent"); const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
const authPath = path.join(agentDir, "auth-profiles.json"); const authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const authStat = await safeStat(authPath); const authPerms = await inspectPathPermissions(authPath, {
if (authStat.ok) { env: params.env,
const bits = modeBits(authStat.mode); platform: params.platform,
if (isWorldWritable(bits) || isGroupWritable(bits)) { exec: params.execIcacls,
});
if (authPerms.ok) {
if (authPerms.worldWritable || authPerms.groupWritable) {
findings.push({ findings.push({
checkId: "fs.auth_profiles.perms_writable", checkId: "fs.auth_profiles.perms_writable",
severity: "critical", severity: "critical",
title: "auth-profiles.json is writable by others", title: "auth-profiles.json is writable by others",
detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`, detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
remediation: `chmod 600 ${authPath}`, remediation: formatPermissionRemediation({
targetPath: authPath,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} else if (isWorldReadable(bits) || isGroupReadable(bits)) { } else if (authPerms.worldReadable || authPerms.groupReadable) {
findings.push({ findings.push({
checkId: "fs.auth_profiles.perms_readable", checkId: "fs.auth_profiles.perms_readable",
severity: "warn", severity: "warn",
title: "auth-profiles.json is readable by others", title: "auth-profiles.json is readable by others",
detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`, detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
remediation: `chmod 600 ${authPath}`, remediation: formatPermissionRemediation({
targetPath: authPath,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} }
} }
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const storeStat = await safeStat(storePath); const storePerms = await inspectPathPermissions(storePath, {
if (storeStat.ok) { env: params.env,
const bits = modeBits(storeStat.mode); platform: params.platform,
if (isWorldReadable(bits) || isGroupReadable(bits)) { exec: params.execIcacls,
});
if (storePerms.ok) {
if (storePerms.worldReadable || storePerms.groupReadable) {
findings.push({ findings.push({
checkId: "fs.sessions_store.perms_readable", checkId: "fs.sessions_store.perms_readable",
severity: "warn", severity: "warn",
title: "sessions.json is readable by others", title: "sessions.json is readable by others",
detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`, detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
remediation: `chmod 600 ${storePath}`, remediation: formatPermissionRemediation({
targetPath: storePath,
perms: storePerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} }
} }
@@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: {
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile; const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
if (expanded) { if (expanded) {
const logPath = path.resolve(expanded); const logPath = path.resolve(expanded);
const st = await safeStat(logPath); const logPerms = await inspectPathPermissions(logPath, {
if (st.ok) { env: params.env,
const bits = modeBits(st.mode); platform: params.platform,
if (isWorldReadable(bits) || isGroupReadable(bits)) { exec: params.execIcacls,
});
if (logPerms.ok) {
if (logPerms.worldReadable || logPerms.groupReadable) {
findings.push({ findings.push({
checkId: "fs.log_file.perms_readable", checkId: "fs.log_file.perms_readable",
severity: "warn", severity: "warn",
title: "Log file is readable by others", title: "Log file is readable by others",
detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`, detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
remediation: `chmod 600 ${logPath}`, remediation: formatPermissionRemediation({
targetPath: logPath,
perms: logPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} }
} }

View File

@@ -1,5 +1,33 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import {
formatIcaclsResetCommand,
formatWindowsAclSummary,
inspectWindowsAcl,
type ExecFn,
} from "./windows-acl.js";
export type PermissionCheck = {
ok: boolean;
isSymlink: boolean;
isDir: boolean;
mode: number | null;
bits: number | null;
source: "posix" | "windows-acl" | "unknown";
worldWritable: boolean;
groupWritable: boolean;
worldReadable: boolean;
groupReadable: boolean;
aclSummary?: string;
error?: string;
};
export type PermissionCheckOptions = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
exec?: ExecFn;
};
export async function safeStat(targetPath: string): Promise<{ export async function safeStat(targetPath: string): Promise<{
ok: boolean; ok: boolean;
isSymlink: boolean; isSymlink: boolean;
@@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{
} }
} }
export async function inspectPathPermissions(
targetPath: string,
opts?: PermissionCheckOptions,
): Promise<PermissionCheck> {
const st = await safeStat(targetPath);
if (!st.ok) {
return {
ok: false,
isSymlink: false,
isDir: false,
mode: null,
bits: null,
source: "unknown",
worldWritable: false,
groupWritable: false,
worldReadable: false,
groupReadable: false,
error: st.error,
};
}
const bits = modeBits(st.mode);
const platform = opts?.platform ?? process.platform;
if (platform === "win32") {
const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
if (!acl.ok) {
return {
ok: true,
isSymlink: st.isSymlink,
isDir: st.isDir,
mode: st.mode,
bits,
source: "unknown",
worldWritable: false,
groupWritable: false,
worldReadable: false,
groupReadable: false,
error: acl.error,
};
}
return {
ok: true,
isSymlink: st.isSymlink,
isDir: st.isDir,
mode: st.mode,
bits,
source: "windows-acl",
worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
aclSummary: formatWindowsAclSummary(acl),
};
}
return {
ok: true,
isSymlink: st.isSymlink,
isDir: st.isDir,
mode: st.mode,
bits,
source: "posix",
worldWritable: isWorldWritable(bits),
groupWritable: isGroupWritable(bits),
worldReadable: isWorldReadable(bits),
groupReadable: isGroupReadable(bits),
};
}
export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
if (perms.source === "windows-acl") {
const summary = perms.aclSummary ?? "unknown";
return `${targetPath} acl=${summary}`;
}
return `${targetPath} mode=${formatOctal(perms.bits)}`;
}
export function formatPermissionRemediation(params: {
targetPath: string;
perms: PermissionCheck;
isDir: boolean;
posixMode: number;
env?: NodeJS.ProcessEnv;
}): string {
if (params.perms.source === "windows-acl") {
return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
}
const mode = params.posixMode.toString(8).padStart(3, "0");
return `chmod ${mode} ${params.targetPath}`;
}
export function modeBits(mode: number | null): number | null { export function modeBits(mode: number | null): number | null {
if (mode == null) return null; if (mode == null) return null;
return mode & 0o777; return mode & 0o777;

View File

@@ -120,6 +120,83 @@ describe("security audit", () => {
); );
}); });
it("treats Windows ACL-only perms as secure", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "clawdbot.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
const user = "DESKTOP-TEST\\Tester";
const execIcacls = async (_cmd: string, args: string[]) => ({
stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
});
const res = await runSecurityAudit({
config: {},
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: "win32",
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
execIcacls,
});
const forbidden = new Set([
"fs.state_dir.perms_world_writable",
"fs.state_dir.perms_group_writable",
"fs.state_dir.perms_readable",
"fs.config.perms_writable",
"fs.config.perms_world_readable",
"fs.config.perms_group_readable",
]);
for (const id of forbidden) {
expect(res.findings.some((f) => f.checkId === id)).toBe(false);
}
});
it("flags Windows ACLs when Users can read the state dir", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-open-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "clawdbot.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
const user = "DESKTOP-TEST\\Tester";
const execIcacls = async (_cmd: string, args: string[]) => {
const target = args[0];
if (target === stateDir) {
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`,
stderr: "",
};
}
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
};
};
const res = await runSecurityAudit({
config: {},
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: "win32",
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
execIcacls,
});
expect(
res.findings.some(
(f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn",
),
).toBe(true);
});
it("warns when small models are paired with web/browser tools", async () => { it("warns when small models are paired with web/browser tools", async () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } }, agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },

View File

@@ -24,14 +24,11 @@ import {
import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import { import {
formatOctal, formatPermissionDetail,
isGroupReadable, formatPermissionRemediation,
isGroupWritable, inspectPathPermissions,
isWorldReadable,
isWorldWritable,
modeBits,
safeStat,
} from "./audit-fs.js"; } from "./audit-fs.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditSeverity = "info" | "warn" | "critical"; export type SecurityAuditSeverity = "info" | "warn" | "critical";
@@ -66,6 +63,8 @@ export type SecurityAuditReport = {
export type SecurityAuditOptions = { export type SecurityAuditOptions = {
config: ClawdbotConfig; config: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
deep?: boolean; deep?: boolean;
includeFilesystem?: boolean; includeFilesystem?: boolean;
includeChannelSecurity?: boolean; includeChannelSecurity?: boolean;
@@ -79,6 +78,8 @@ export type SecurityAuditOptions = {
plugins?: ReturnType<typeof listChannelPlugins>; plugins?: ReturnType<typeof listChannelPlugins>;
/** Dependency injection for tests. */ /** Dependency injection for tests. */
probeGatewayFn?: typeof probeGateway; probeGatewayFn?: typeof probeGateway;
/** Dependency injection for tests (Windows ACL checks). */
execIcacls?: ExecFn;
}; };
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
@@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
async function collectFilesystemFindings(params: { async function collectFilesystemFindings(params: {
stateDir: string; stateDir: string;
configPath: string; configPath: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> { }): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = []; const findings: SecurityAuditFinding[] = [];
const stateDirStat = await safeStat(params.stateDir); const stateDirPerms = await inspectPathPermissions(params.stateDir, {
if (stateDirStat.ok) { env: params.env,
const bits = modeBits(stateDirStat.mode); platform: params.platform,
if (stateDirStat.isSymlink) { exec: params.execIcacls,
});
if (stateDirPerms.ok) {
if (stateDirPerms.isSymlink) {
findings.push({ findings.push({
checkId: "fs.state_dir.symlink", checkId: "fs.state_dir.symlink",
severity: "warn", severity: "warn",
@@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: {
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`, detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
}); });
} }
if (isWorldWritable(bits)) { if (stateDirPerms.worldWritable) {
findings.push({ findings.push({
checkId: "fs.state_dir.perms_world_writable", checkId: "fs.state_dir.perms_world_writable",
severity: "critical", severity: "critical",
title: "State dir is world-writable", title: "State dir is world-writable",
detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`, detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`,
remediation: `chmod 700 ${params.stateDir}`, remediation: formatPermissionRemediation({
targetPath: params.stateDir,
perms: stateDirPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
}); });
} else if (isGroupWritable(bits)) { } else if (stateDirPerms.groupWritable) {
findings.push({ findings.push({
checkId: "fs.state_dir.perms_group_writable", checkId: "fs.state_dir.perms_group_writable",
severity: "warn", severity: "warn",
title: "State dir is group-writable", title: "State dir is group-writable",
detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`, detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`,
remediation: `chmod 700 ${params.stateDir}`, remediation: formatPermissionRemediation({
targetPath: params.stateDir,
perms: stateDirPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
}); });
} else if (isGroupReadable(bits) || isWorldReadable(bits)) { } else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
findings.push({ findings.push({
checkId: "fs.state_dir.perms_readable", checkId: "fs.state_dir.perms_readable",
severity: "warn", severity: "warn",
title: "State dir is readable by others", title: "State dir is readable by others",
detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`, detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
remediation: `chmod 700 ${params.stateDir}`, remediation: formatPermissionRemediation({
targetPath: params.stateDir,
perms: stateDirPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
}); });
} }
} }
const configStat = await safeStat(params.configPath); const configPerms = await inspectPathPermissions(params.configPath, {
if (configStat.ok) { env: params.env,
const bits = modeBits(configStat.mode); platform: params.platform,
if (configStat.isSymlink) { exec: params.execIcacls,
});
if (configPerms.ok) {
if (configPerms.isSymlink) {
findings.push({ findings.push({
checkId: "fs.config.symlink", checkId: "fs.config.symlink",
severity: "warn", severity: "warn",
@@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: {
detail: `${params.configPath} is a symlink; make sure you trust its target.`, detail: `${params.configPath} is a symlink; make sure you trust its target.`,
}); });
} }
if (isWorldWritable(bits) || isGroupWritable(bits)) { if (configPerms.worldWritable || configPerms.groupWritable) {
findings.push({ findings.push({
checkId: "fs.config.perms_writable", checkId: "fs.config.perms_writable",
severity: "critical", severity: "critical",
title: "Config file is writable by others", title: "Config file is writable by others",
detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`, detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
remediation: `chmod 600 ${params.configPath}`, remediation: formatPermissionRemediation({
targetPath: params.configPath,
perms: configPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} else if (isWorldReadable(bits)) { } else if (configPerms.worldReadable) {
findings.push({ findings.push({
checkId: "fs.config.perms_world_readable", checkId: "fs.config.perms_world_readable",
severity: "critical", severity: "critical",
title: "Config file is world-readable", title: "Config file is world-readable",
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
remediation: `chmod 600 ${params.configPath}`, remediation: formatPermissionRemediation({
targetPath: params.configPath,
perms: configPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} else if (isGroupReadable(bits)) { } else if (configPerms.groupReadable) {
findings.push({ findings.push({
checkId: "fs.config.perms_group_readable", checkId: "fs.config.perms_group_readable",
severity: "warn", severity: "warn",
title: "Config file is group-readable", title: "Config file is group-readable",
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
remediation: `chmod 600 ${params.configPath}`, remediation: formatPermissionRemediation({
targetPath: params.configPath,
perms: configPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
}); });
} }
} }
@@ -850,7 +896,9 @@ async function maybeProbeGateway(params: {
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> { export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
const findings: SecurityAuditFinding[] = []; const findings: SecurityAuditFinding[] = [];
const cfg = opts.config; const cfg = opts.config;
const env = process.env; const env = opts.env ?? process.env;
const platform = opts.platform ?? process.platform;
const execIcacls = opts.execIcacls;
const stateDir = opts.stateDir ?? resolveStateDir(env); const stateDir = opts.stateDir ?? resolveStateDir(env);
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir); const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
@@ -873,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
: null; : null;
if (opts.includeFilesystem !== false) { if (opts.includeFilesystem !== false) {
findings.push(...(await collectFilesystemFindings({ stateDir, configPath }))); findings.push(
...(await collectFilesystemFindings({
stateDir,
configPath,
env,
platform,
execIcacls,
})),
);
if (configSnapshot) { if (configSnapshot) {
findings.push(...(await collectIncludeFilePermFindings({ configSnapshot }))); findings.push(
...(await collectIncludeFilePermFindings({ configSnapshot, env, platform, execIcacls })),
);
} }
findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir }))); findings.push(
...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })),
);
findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir }))); findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
} }

View File

@@ -10,6 +10,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js"; import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { normalizeAgentId } from "../routing/session-key.js"; 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 { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
export type SecurityFixChmodAction = { export type SecurityFixChmodAction = {
kind: "chmod"; kind: "chmod";
@@ -20,13 +22,24 @@ export type SecurityFixChmodAction = {
error?: string; error?: string;
}; };
export type SecurityFixIcaclsAction = {
kind: "icacls";
path: string;
command: string;
ok: boolean;
skipped?: string;
error?: string;
};
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
export type SecurityFixResult = { export type SecurityFixResult = {
ok: boolean; ok: boolean;
stateDir: string; stateDir: string;
configPath: string; configPath: string;
configWritten: boolean; configWritten: boolean;
changes: string[]; changes: string[];
actions: SecurityFixChmodAction[]; actions: SecurityFixAction[];
errors: string[]; errors: string[];
}; };
@@ -97,6 +110,82 @@ async function safeChmod(params: {
} }
} }
async function safeAclReset(params: {
path: string;
require: "dir" | "file";
env: NodeJS.ProcessEnv;
exec?: ExecFn;
}): Promise<SecurityFixIcaclsAction> {
const display = formatIcaclsResetCommand(params.path, {
isDir: params.require === "dir",
env: params.env,
});
try {
const st = await fs.lstat(params.path);
if (st.isSymbolicLink()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "symlink",
};
}
if (params.require === "dir" && !st.isDirectory()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "not-a-directory",
};
}
if (params.require === "file" && !st.isFile()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "not-a-file",
};
}
const cmd = createIcaclsResetCommand(params.path, {
isDir: st.isDirectory(),
env: params.env,
});
if (!cmd) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "missing-user",
};
}
const exec = params.exec ?? runExec;
await exec(cmd.command, cmd.args);
return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "missing",
};
}
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
error: String(err),
};
}
}
function setGroupPolicyAllowlist(params: { function setGroupPolicyAllowlist(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
channel: string; channel: string;
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
stateDir: string; stateDir: string;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
actions: SecurityFixChmodAction[]; actions: SecurityFixAction[];
applyPerms: (params: {
path: string;
mode: number;
require: "dir" | "file";
}) => Promise<SecurityFixAction>;
}): Promise<void> { }): Promise<void> {
const credsDir = resolveOAuthDir(params.env, params.stateDir); const credsDir = resolveOAuthDir(params.env, params.stateDir);
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" })); params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
@@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" })); params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" })); params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" }));
const authPath = path.join(agentDir, "auth-profiles.json"); const authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" })); params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" }));
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" })); params.actions.push(
await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }),
);
const storePath = path.join(sessionsDir, "sessions.json"); const storePath = path.join(sessionsDir, "sessions.json");
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" })); params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" }));
} }
} }
@@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
stateDir?: string; stateDir?: string;
configPath?: string; configPath?: string;
platform?: NodeJS.Platform;
exec?: ExecFn;
}): Promise<SecurityFixResult> { }): Promise<SecurityFixResult> {
const env = opts?.env ?? process.env; const env = opts?.env ?? process.env;
const platform = opts?.platform ?? process.platform;
const exec = opts?.exec ?? runExec;
const isWindows = platform === "win32";
const stateDir = opts?.stateDir ?? resolveStateDir(env); const stateDir = opts?.stateDir ?? resolveStateDir(env);
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir); const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
const actions: SecurityFixChmodAction[] = []; const actions: SecurityFixAction[] = [];
const errors: string[] = []; const errors: string[] = [];
const io = createConfigIO({ env, configPath }); const io = createConfigIO({ env, configPath });
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
} }
} }
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" })); const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" })); isWindows
? safeAclReset({ path: params.path, require: params.require, env, exec })
: safeChmod({ path: params.path, mode: params.mode, require: params.require });
actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" }));
actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" }));
if (snap.exists) { if (snap.exists) {
const includePaths = await collectIncludePathsRecursive({ const includePaths = await collectIncludePathsRecursive({
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
}).catch(() => []); }).catch(() => []);
for (const p of includePaths) { for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" })); actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" }));
} }
} }
await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch( await chmodCredentialsAndAgentState({
(err) => { env,
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`); stateDir,
}, cfg: snap.config ?? {},
); actions,
applyPerms,
}).catch((err) => {
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
});
return { return {
ok: errors.length === 0, ok: errors.length === 0,

203
src/security/windows-acl.ts Normal file
View File

@@ -0,0 +1,203 @@
import os from "node:os";
import { runExec } from "../process/exec.js";
export type ExecFn = typeof runExec;
export type WindowsAclEntry = {
principal: string;
rights: string[];
rawRights: string;
canRead: boolean;
canWrite: boolean;
};
export type WindowsAclSummary = {
ok: boolean;
entries: WindowsAclEntry[];
untrustedWorld: WindowsAclEntry[];
untrustedGroup: WindowsAclEntry[];
trusted: WindowsAclEntry[];
error?: string;
};
const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
const WORLD_PRINCIPALS = new Set([
"everyone",
"users",
"builtin\\users",
"authenticated users",
"nt authority\\authenticated users",
]);
const TRUSTED_BASE = new Set([
"nt authority\\system",
"system",
"builtin\\administrators",
"creator owner",
]);
const WORLD_SUFFIXES = ["\\users", "\\authenticated users"];
const TRUSTED_SUFFIXES = ["\\administrators", "\\system"];
const normalize = (value: string) => value.trim().toLowerCase();
export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null {
const username = env?.USERNAME?.trim() || os.userInfo().username?.trim();
if (!username) return null;
const domain = env?.USERDOMAIN?.trim();
return domain ? `${domain}\\${username}` : username;
}
function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set<string> {
const trusted = new Set<string>(TRUSTED_BASE);
const principal = resolveWindowsUserPrincipal(env);
if (principal) {
trusted.add(normalize(principal));
const parts = principal.split("\\");
const userOnly = parts.at(-1);
if (userOnly) trusted.add(normalize(userOnly));
}
return trusted;
}
function classifyPrincipal(
principal: string,
env?: NodeJS.ProcessEnv,
): "trusted" | "world" | "group" {
const normalized = normalize(principal);
const trusted = buildTrustedPrincipals(env);
if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s)))
return "trusted";
if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s)))
return "world";
return "group";
}
function rightsFromTokens(tokens: string[]): { canRead: boolean; canWrite: boolean } {
const upper = tokens.join("").toUpperCase();
const canWrite =
upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D");
const canRead = upper.includes("F") || upper.includes("M") || upper.includes("R");
return { canRead, canWrite };
}
export function parseIcaclsOutput(output: string, targetPath: string): WindowsAclEntry[] {
const entries: WindowsAclEntry[] = [];
const normalizedTarget = targetPath.trim();
const lowerTarget = normalizedTarget.toLowerCase();
const quotedTarget = `"${normalizedTarget}"`;
const quotedLower = quotedTarget.toLowerCase();
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line.trim()) continue;
const trimmed = line.trim();
const lower = trimmed.toLowerCase();
if (
lower.startsWith("successfully processed") ||
lower.startsWith("processed") ||
lower.startsWith("failed processing") ||
lower.startsWith("no mapping between account names")
) {
continue;
}
let entry = trimmed;
if (lower.startsWith(lowerTarget)) {
entry = trimmed.slice(normalizedTarget.length).trim();
} else if (lower.startsWith(quotedLower)) {
entry = trimmed.slice(quotedTarget.length).trim();
}
if (!entry) continue;
const idx = entry.indexOf(":");
if (idx === -1) continue;
const principal = entry.slice(0, idx).trim();
const rawRights = entry.slice(idx + 1).trim();
const tokens =
rawRights
.match(/\(([^)]+)\)/g)
?.map((token) => token.slice(1, -1).trim())
.filter(Boolean) ?? [];
if (tokens.some((token) => token.toUpperCase() === "DENY")) continue;
const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
if (rights.length === 0) continue;
const { canRead, canWrite } = rightsFromTokens(rights);
entries.push({ principal, rights, rawRights, canRead, canWrite });
}
return entries;
}
export function summarizeWindowsAcl(
entries: WindowsAclEntry[],
env?: NodeJS.ProcessEnv,
): Pick<WindowsAclSummary, "trusted" | "untrustedWorld" | "untrustedGroup"> {
const trusted: WindowsAclEntry[] = [];
const untrustedWorld: WindowsAclEntry[] = [];
const untrustedGroup: WindowsAclEntry[] = [];
for (const entry of entries) {
const classification = classifyPrincipal(entry.principal, env);
if (classification === "trusted") trusted.push(entry);
else if (classification === "world") untrustedWorld.push(entry);
else untrustedGroup.push(entry);
}
return { trusted, untrustedWorld, untrustedGroup };
}
export async function inspectWindowsAcl(
targetPath: string,
opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn },
): Promise<WindowsAclSummary> {
const exec = opts?.exec ?? runExec;
try {
const { stdout, stderr } = await exec("icacls", [targetPath]);
const output = `${stdout}\n${stderr}`.trim();
const entries = parseIcaclsOutput(output, targetPath);
const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env);
return { ok: true, entries, trusted, untrustedWorld, untrustedGroup };
} catch (err) {
return {
ok: false,
entries: [],
trusted: [],
untrustedWorld: [],
untrustedGroup: [],
error: String(err),
};
}
}
export function formatWindowsAclSummary(summary: WindowsAclSummary): string {
if (!summary.ok) return "unknown";
const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup];
if (untrusted.length === 0) return "trusted-only";
return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", ");
}
export function formatIcaclsResetCommand(
targetPath: string,
opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
): string {
const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%";
const grant = opts.isDir ? "(OI)(CI)F" : "F";
return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`;
}
export function createIcaclsResetCommand(
targetPath: string,
opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
): { command: string; args: string[]; display: string } | null {
const user = resolveWindowsUserPrincipal(opts.env);
if (!user) return null;
const grant = opts.isDir ? "(OI)(CI)F" : "F";
const args = [
targetPath,
"/inheritance:r",
"/grant:r",
`${user}:${grant}`,
"/grant:r",
`SYSTEM:${grant}`,
];
return { command: "icacls", args, display: formatIcaclsResetCommand(targetPath, opts) };
}