diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e14f73d..d95c8c124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index 42bca4ca4..2bd5a36b7 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -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) { diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 6dce5c896..9aabb9721 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -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 { 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 { 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, + }), }); } } diff --git a/src/security/audit-fs.ts b/src/security/audit-fs.ts index 5832b64f8..6bf0aec26 100644 --- a/src/security/audit-fs.ts +++ b/src/security/audit-fs.ts @@ -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 { + 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; diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 294384abd..7dc0dd263 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -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" } } }, diff --git a/src/security/audit.ts b/src/security/audit.ts index 5b6df61b8..2169f197d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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; /** 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 { 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 { 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 { + 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; }): Promise { 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 { 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, diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts new file mode 100644 index 000000000..0a6779214 --- /dev/null +++ b/src/security/windows-acl.ts @@ -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 { + const trusted = new Set(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 { + 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 { + 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) }; +}