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: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- 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.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- 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)}`));
}
for (const action of fixResult.actions) {
const mode = action.mode.toString(8).padStart(3, "0");
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
else if (action.skipped)
lines.push(
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
);
else if (action.error)
lines.push(
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
);
if (action.kind === "chmod") {
const mode = action.mode.toString(8).padStart(3, "0");
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
else if (action.skipped)
lines.push(
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
);
else if (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) {
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 { normalizeAgentId } from "../routing/session-key.js";
import {
formatOctal,
isGroupReadable,
isGroupWritable,
isWorldReadable,
isWorldWritable,
modeBits,
formatPermissionDetail,
formatPermissionRemediation,
inspectPathPermissions,
safeStat,
} from "./audit-fs.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditFinding = {
checkId: string;
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
export async function collectIncludeFilePermFindings(params: {
configSnapshot: ConfigFileSnapshot;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
if (!params.configSnapshot.exists) return findings;
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop
const st = await safeStat(p);
if (!st.ok) continue;
const bits = modeBits(st.mode);
if (isWorldWritable(bits) || isGroupWritable(bits)) {
const perms = await inspectPathPermissions(p, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (!perms.ok) continue;
if (perms.worldWritable || perms.groupWritable) {
findings.push({
checkId: "fs.config_include.perms_writable",
severity: "critical",
title: "Config include file is writable by others",
detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
remediation: `chmod 600 ${p}`,
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isWorldReadable(bits)) {
} else if (perms.worldReadable) {
findings.push({
checkId: "fs.config_include.perms_world_readable",
severity: "critical",
title: "Config include file is world-readable",
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
remediation: `chmod 600 ${p}`,
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isGroupReadable(bits)) {
} else if (perms.groupReadable) {
findings.push({
checkId: "fs.config_include.perms_group_readable",
severity: "warn",
title: "Config include file is group-readable",
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
remediation: `chmod 600 ${p}`,
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
cfg: ClawdbotConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
const oauthStat = await safeStat(oauthDir);
if (oauthStat.ok && oauthStat.isDir) {
const bits = modeBits(oauthStat.mode);
if (isWorldWritable(bits) || isGroupWritable(bits)) {
const oauthPerms = await inspectPathPermissions(oauthDir, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (oauthPerms.ok && oauthPerms.isDir) {
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
findings.push({
checkId: "fs.credentials_dir.perms_writable",
severity: "critical",
title: "Credentials dir is writable by others",
detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
remediation: `chmod 700 ${oauthDir}`,
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
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({
checkId: "fs.credentials_dir.perms_readable",
severity: "warn",
title: "Credentials dir is readable by others",
detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
remediation: `chmod 700 ${oauthDir}`,
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
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 authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop
const authStat = await safeStat(authPath);
if (authStat.ok) {
const bits = modeBits(authStat.mode);
if (isWorldWritable(bits) || isGroupWritable(bits)) {
const authPerms = await inspectPathPermissions(authPath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (authPerms.ok) {
if (authPerms.worldWritable || authPerms.groupWritable) {
findings.push({
checkId: "fs.auth_profiles.perms_writable",
severity: "critical",
title: "auth-profiles.json is writable by others",
detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
remediation: `chmod 600 ${authPath}`,
detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
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({
checkId: "fs.auth_profiles.perms_readable",
severity: "warn",
title: "auth-profiles.json is readable by others",
detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
remediation: `chmod 600 ${authPath}`,
detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
remediation: formatPermissionRemediation({
targetPath: authPath,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
// eslint-disable-next-line no-await-in-loop
const storeStat = await safeStat(storePath);
if (storeStat.ok) {
const bits = modeBits(storeStat.mode);
if (isWorldReadable(bits) || isGroupReadable(bits)) {
const storePerms = await inspectPathPermissions(storePath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (storePerms.ok) {
if (storePerms.worldReadable || storePerms.groupReadable) {
findings.push({
checkId: "fs.sessions_store.perms_readable",
severity: "warn",
title: "sessions.json is readable by others",
detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
remediation: `chmod 600 ${storePath}`,
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
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;
if (expanded) {
const logPath = path.resolve(expanded);
const st = await safeStat(logPath);
if (st.ok) {
const bits = modeBits(st.mode);
if (isWorldReadable(bits) || isGroupReadable(bits)) {
const logPerms = await inspectPathPermissions(logPath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (logPerms.ok) {
if (logPerms.worldReadable || logPerms.groupReadable) {
findings.push({
checkId: "fs.log_file.perms_readable",
severity: "warn",
title: "Log file is readable by others",
detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
remediation: `chmod 600 ${logPath}`,
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
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 {
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<{
ok: 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 {
if (mode == null) return null;
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 () => {
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },

View File

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

View File

@@ -10,6 +10,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { normalizeAgentId } from "../routing/session-key.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 = {
kind: "chmod";
@@ -20,13 +22,24 @@ export type SecurityFixChmodAction = {
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 = {
ok: boolean;
stateDir: string;
configPath: string;
configWritten: boolean;
changes: string[];
actions: SecurityFixChmodAction[];
actions: SecurityFixAction[];
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: {
cfg: ClawdbotConfig;
channel: string;
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
env: NodeJS.ProcessEnv;
stateDir: string;
cfg: ClawdbotConfig;
actions: SecurityFixChmodAction[];
actions: SecurityFixAction[];
applyPerms: (params: {
path: string;
mode: number;
require: "dir" | "file";
}) => Promise<SecurityFixAction>;
}): Promise<void> {
const credsDir = resolveOAuthDir(params.env, params.stateDir);
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
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
// 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");
// 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
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");
// 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;
stateDir?: string;
configPath?: string;
platform?: NodeJS.Platform;
exec?: ExecFn;
}): Promise<SecurityFixResult> {
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 configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
const actions: SecurityFixChmodAction[] = [];
const actions: SecurityFixAction[] = [];
const errors: string[] = [];
const io = createConfigIO({ env, configPath });
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
}
}
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
const applyPerms = (params: { path: string; mode: number; require: "dir" | "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) {
const includePaths = await collectIncludePathsRecursive({
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
}).catch(() => []);
for (const p of includePaths) {
// 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(
(err) => {
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
},
);
await chmodCredentialsAndAgentState({
env,
stateDir,
cfg: snap.config ?? {},
actions,
applyPerms,
}).catch((err) => {
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
});
return {
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) };
}