diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e14f73d..16c1a05ff 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. @@ -42,6 +43,9 @@ Status: unreleased. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. +### Breaking +- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). + ### Fixes - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. @@ -52,6 +56,7 @@ Status: unreleased. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. - Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. - Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). +- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present. - Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. ## 2026.1.24-3 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/gateway/auth.test.ts b/src/gateway/auth.test.ts index 90bd5c41e..7e1022124 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js"; describe("gateway auth", () => { it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: false }, - connectAuth: null, + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "secret" }, // Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage. req: {} as never, }); @@ -63,40 +63,10 @@ describe("gateway auth", () => { expect(res.reason).toBe("password_missing_config"); }); - it("reports tailscale auth reasons when required", async () => { - const reqBase = { - socket: { remoteAddress: "100.100.100.100" }, - headers: { host: "gateway.local" }, - }; - - const missingUser = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - req: reqBase as never, - }); - expect(missingUser.ok).toBe(false); - expect(missingUser.reason).toBe("tailscale_user_missing"); - - const missingProxy = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - req: { - ...reqBase, - headers: { - host: "gateway.local", - "tailscale-user-login": "peter", - "tailscale-user-name": "Peter", - }, - } as never, - }); - expect(missingProxy.ok).toBe(false); - expect(missingProxy.reason).toBe("tailscale_proxy_missing"); - }); - it("treats local tailscale serve hostnames as direct", async () => { const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, + auth: { mode: "token", token: "secret", allowTailscale: true }, + connectAuth: { token: "secret" }, req: { socket: { remoteAddress: "127.0.0.1" }, headers: { host: "gateway.tailnet-1234.ts.net:443" }, @@ -104,21 +74,7 @@ describe("gateway auth", () => { }); expect(res.ok).toBe(true); - expect(res.method).toBe("none"); - }); - - it("does not treat tailscale clients as direct", async () => { - const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - req: { - socket: { remoteAddress: "100.64.0.42" }, - headers: { host: "gateway.tailnet-1234.ts.net" }, - } as never, - }); - - expect(res.ok).toBe(false); - expect(res.reason).toBe("tailscale_user_missing"); + expect(res.method).toBe("token"); }); it("allows tailscale identity to satisfy token mode auth", async () => { @@ -143,41 +99,4 @@ describe("gateway auth", () => { expect(res.method).toBe("tailscale"); expect(res.user).toBe("peter"); }); - - it("rejects mismatched tailscale identity when required", async () => { - const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }), - req: { - socket: { remoteAddress: "127.0.0.1" }, - headers: { - host: "gateway.local", - "x-forwarded-for": "100.64.0.1", - "x-forwarded-proto": "https", - "x-forwarded-host": "ai-hub.bone-egret.ts.net", - "tailscale-user-login": "peter@example.com", - "tailscale-user-name": "Peter", - }, - } as never, - }); - - expect(res.ok).toBe(false); - expect(res.reason).toBe("tailscale_user_mismatch"); - }); - - it("treats trusted proxy loopback clients as direct", async () => { - const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - trustedProxies: ["10.0.0.2"], - req: { - socket: { remoteAddress: "10.0.0.2" }, - headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" }, - } as never, - }); - - expect(res.ok).toBe(true); - expect(res.method).toBe("none"); - }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index f716be5dd..1adc367a2 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js"; -export type ResolvedGatewayAuthMode = "none" | "token" | "password"; +export type ResolvedGatewayAuthMode = "token" | "password"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; @@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = { export type GatewayAuthResult = { ok: boolean; - method?: "none" | "token" | "password" | "tailscale" | "device-token"; + method?: "token" | "password" | "tailscale" | "device-token"; user?: string; reason?: string; }; @@ -84,7 +84,7 @@ function resolveRequestClientIp( }); } -function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { +export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { if (!req) return false; const clientIp = resolveRequestClientIp(req, trustedProxies) ?? ""; if (!isLoopbackAddress(clientIp)) return false; @@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: { user: tailscaleCheck.user.login, }; } - if (auth.mode === "none") { - return { ok: false, reason: tailscaleCheck.reason }; - } - } - - if (auth.mode === "none") { - return { ok: true, method: "none" }; } if (auth.mode === "token") { diff --git a/src/gateway/gateway.e2e.test.ts b/src/gateway/gateway.e2e.test.ts index 47ce694ce..0f65d16ac 100644 --- a/src/gateway/gateway.e2e.test.ts +++ b/src/gateway/gateway.e2e.test.ts @@ -181,7 +181,7 @@ describe("gateway e2e", () => { const port = await getFreeGatewayPort(); const server = await startGatewayServer(port, { bind: "loopback", - auth: { mode: "none" }, + auth: { mode: "token", token: wizardToken }, controlUiEnabled: false, wizardRunner: async (_opts, _runtime, prompter) => { await prompter.intro("Wizard E2E"); @@ -197,6 +197,7 @@ describe("gateway e2e", () => { const client = await connectGatewayClient({ url: `ws://127.0.0.1:${port}`, + token: wizardToken, clientDisplayName: "vitest-wizard", }); diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 3f9994205..2eb3dcef9 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => { await new Promise((resolve) => ws.once("close", () => resolve())); }); + test("requires nonce when host is non-local", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { host: "example.com" }, + }); + await new Promise((resolve) => ws.once("open", resolve)); + + const res = await connectReq(ws); + expect(res.ok).toBe(false); + expect(res.error?.message).toBe("device nonce required"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + test( "invalid connect params surface in response and close reason", { timeout: 60_000 }, @@ -290,6 +302,7 @@ describe("gateway server auth/connect", () => { test("allows control ui with device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; + testState.gatewayAuth = { mode: "token", token: "secret" }; const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ gateway: { @@ -354,6 +367,7 @@ describe("gateway server auth/connect", () => { test("allows control ui with stale device identity when device auth is disabled", async () => { testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; + testState.gatewayAuth = { mode: "token", token: "secret" }; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; const port = await getFreePort(); @@ -399,28 +413,6 @@ describe("gateway server auth/connect", () => { } }); - test("rejects proxied connections without auth when proxy headers are untrusted", async () => { - testState.gatewayAuth = { mode: "none" }; - const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { "x-forwarded-for": "203.0.113.10" }, - }); - await new Promise((resolve) => ws.once("open", resolve)); - const res = await connectReq(ws, { skipDefaultAuth: true }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("gateway auth required"); - ws.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - } else { - process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; - } - }); - test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 3ff455295..d1f6ae511 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; -import { authorizeGatewayConnect } from "../../auth.js"; +import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; +import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType; const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000; +function resolveHostName(hostHeader?: string): string { + const host = (hostHeader ?? "").trim().toLowerCase(); + if (!host) return ""; + if (host.startsWith("[")) { + const end = host.indexOf("]"); + if (end !== -1) return host.slice(1, end); + } + const [name] = host.split(":"); + return name ?? ""; +} + type AuthProvidedKind = "token" | "password" | "none"; function formatGatewayAuthFailureMessage(params: { @@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: { const hasProxyHeaders = Boolean(forwardedFor || realIp); const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies); const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; - const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp); - const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp; + const hostName = resolveHostName(requestHost); + const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1"; + const hostIsTailscaleServe = hostName.endsWith(".ts.net"); + const hostIsLocalish = hostIsLocal || hostIsTailscaleServe; + const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies); + const reportedClientIp = + isLocalClient || hasUntrustedProxyHeaders + ? undefined + : clientIp && !isLoopbackAddress(clientIp) + ? clientIp + : undefined; if (hasUntrustedProxyHeaders) { logWsControl.warn( @@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: { "Configure gateway.trustedProxies to restore local client detection behind your proxy.", ); } + if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) { + logWsControl.warn( + "Loopback connection with non-local Host header. " + + "Treating it as remote. If you're behind a reverse proxy, " + + "set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.", + ); + } const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); @@ -347,32 +374,6 @@ export function attachGatewayWsMessageHandler(params: { isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true; const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth; const device = disableControlUiDeviceAuth ? null : deviceRaw; - if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") { - setHandshakeState("failed"); - setCloseCause("proxy-auth-required", { - client: connectParams.client.id, - clientDisplayName: connectParams.client.displayName, - mode: connectParams.client.mode, - version: connectParams.client.version, - }); - send({ - type: "res", - id: frame.id, - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - "gateway auth required behind reverse proxy", - { - details: { - hint: "set gateway.auth or configure gateway.trustedProxies", - }, - }, - ), - }); - close(1008, "gateway auth required"); - return; - } - if (!device) { const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth; @@ -570,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: { trustedProxies, }); let authOk = authResult.ok; - let authMethod = authResult.method ?? "none"; + let authMethod = + authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); if (!authOk && connectParams.auth?.token && device) { const tokenCheck = await verifyDeviceToken({ deviceId: device.id, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 254365564..34c22c573 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -260,6 +260,9 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) { let port = await getFreePort(); const prev = process.env.CLAWDBOT_GATEWAY_TOKEN; + if (typeof token === "string") { + testState.gatewayAuth = { mode: "token", token }; + } const fallbackToken = token ?? (typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" 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..e87a6b47c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -82,7 +82,7 @@ describe("security audit", () => { gateway: { bind: "loopback", controlUi: { enabled: true }, - auth: { mode: "none" as any }, + auth: {}, }, }; @@ -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) }; +}