fix: use Windows ACLs for security audit
This commit is contained in:
@@ -34,6 +34,7 @@ Status: unreleased.
|
|||||||
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||||
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
||||||
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||||
|
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
|
||||||
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
||||||
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
||||||
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function registerSecurityCli(program: Command) {
|
|||||||
lines.push(muted(` ${shortenHomeInString(change)}`));
|
lines.push(muted(` ${shortenHomeInString(change)}`));
|
||||||
}
|
}
|
||||||
for (const action of fixResult.actions) {
|
for (const action of fixResult.actions) {
|
||||||
|
if (action.kind === "chmod") {
|
||||||
const mode = action.mode.toString(8).padStart(3, "0");
|
const mode = action.mode.toString(8).padStart(3, "0");
|
||||||
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
|
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
|
||||||
else if (action.skipped)
|
else if (action.skipped)
|
||||||
@@ -97,6 +98,12 @@ export function registerSecurityCli(program: Command) {
|
|||||||
lines.push(
|
lines.push(
|
||||||
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
|
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const command = shortenHomeInString(action.command);
|
||||||
|
if (action.ok) lines.push(muted(` ${command}`));
|
||||||
|
else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
|
||||||
|
else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
|
||||||
}
|
}
|
||||||
if (fixResult.errors.length > 0) {
|
if (fixResult.errors.length > 0) {
|
||||||
for (const err of fixResult.errors) {
|
for (const err of fixResult.errors) {
|
||||||
|
|||||||
@@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
|||||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
formatOctal,
|
formatPermissionDetail,
|
||||||
isGroupReadable,
|
formatPermissionRemediation,
|
||||||
isGroupWritable,
|
inspectPathPermissions,
|
||||||
isWorldReadable,
|
|
||||||
isWorldWritable,
|
|
||||||
modeBits,
|
|
||||||
safeStat,
|
safeStat,
|
||||||
} from "./audit-fs.js";
|
} from "./audit-fs.js";
|
||||||
|
import type { ExecFn } from "./windows-acl.js";
|
||||||
|
|
||||||
export type SecurityAuditFinding = {
|
export type SecurityAuditFinding = {
|
||||||
checkId: string;
|
checkId: string;
|
||||||
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
|
|||||||
|
|
||||||
export async function collectIncludeFilePermFindings(params: {
|
export async function collectIncludeFilePermFindings(params: {
|
||||||
configSnapshot: ConfigFileSnapshot;
|
configSnapshot: ConfigFileSnapshot;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
execIcacls?: ExecFn;
|
||||||
}): Promise<SecurityAuditFinding[]> {
|
}): Promise<SecurityAuditFinding[]> {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
if (!params.configSnapshot.exists) return findings;
|
if (!params.configSnapshot.exists) return findings;
|
||||||
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
|
|||||||
|
|
||||||
for (const p of includePaths) {
|
for (const p of includePaths) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const st = await safeStat(p);
|
const perms = await inspectPathPermissions(p, {
|
||||||
if (!st.ok) continue;
|
env: params.env,
|
||||||
const bits = modeBits(st.mode);
|
platform: params.platform,
|
||||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (!perms.ok) continue;
|
||||||
|
if (perms.worldWritable || perms.groupWritable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config_include.perms_writable",
|
checkId: "fs.config_include.perms_writable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "Config include file is writable by others",
|
title: "Config include file is writable by others",
|
||||||
detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
|
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
|
||||||
remediation: `chmod 600 ${p}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: p,
|
||||||
|
perms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isWorldReadable(bits)) {
|
} else if (perms.worldReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config_include.perms_world_readable",
|
checkId: "fs.config_include.perms_world_readable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "Config include file is world-readable",
|
title: "Config include file is world-readable",
|
||||||
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
|
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||||
remediation: `chmod 600 ${p}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: p,
|
||||||
|
perms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isGroupReadable(bits)) {
|
} else if (perms.groupReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config_include.perms_group_readable",
|
checkId: "fs.config_include.perms_group_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "Config include file is group-readable",
|
title: "Config include file is group-readable",
|
||||||
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
|
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||||
remediation: `chmod 600 ${p}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: p,
|
||||||
|
perms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
execIcacls?: ExecFn;
|
||||||
}): Promise<SecurityAuditFinding[]> {
|
}): Promise<SecurityAuditFinding[]> {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
|
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
|
||||||
|
|
||||||
const oauthStat = await safeStat(oauthDir);
|
const oauthPerms = await inspectPathPermissions(oauthDir, {
|
||||||
if (oauthStat.ok && oauthStat.isDir) {
|
env: params.env,
|
||||||
const bits = modeBits(oauthStat.mode);
|
platform: params.platform,
|
||||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (oauthPerms.ok && oauthPerms.isDir) {
|
||||||
|
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.credentials_dir.perms_writable",
|
checkId: "fs.credentials_dir.perms_writable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "Credentials dir is writable by others",
|
title: "Credentials dir is writable by others",
|
||||||
detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
|
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
|
||||||
remediation: `chmod 700 ${oauthDir}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: oauthDir,
|
||||||
|
perms: oauthPerms,
|
||||||
|
isDir: true,
|
||||||
|
posixMode: 0o700,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
|
} else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.credentials_dir.perms_readable",
|
checkId: "fs.credentials_dir.perms_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "Credentials dir is readable by others",
|
title: "Credentials dir is readable by others",
|
||||||
detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
|
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
|
||||||
remediation: `chmod 700 ${oauthDir}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: oauthDir,
|
||||||
|
perms: oauthPerms,
|
||||||
|
isDir: true,
|
||||||
|
posixMode: 0o700,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: {
|
|||||||
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
|
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const authStat = await safeStat(authPath);
|
const authPerms = await inspectPathPermissions(authPath, {
|
||||||
if (authStat.ok) {
|
env: params.env,
|
||||||
const bits = modeBits(authStat.mode);
|
platform: params.platform,
|
||||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (authPerms.ok) {
|
||||||
|
if (authPerms.worldWritable || authPerms.groupWritable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.auth_profiles.perms_writable",
|
checkId: "fs.auth_profiles.perms_writable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "auth-profiles.json is writable by others",
|
title: "auth-profiles.json is writable by others",
|
||||||
detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
|
detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
|
||||||
remediation: `chmod 600 ${authPath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: authPath,
|
||||||
|
perms: authPerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
} else if (authPerms.worldReadable || authPerms.groupReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.auth_profiles.perms_readable",
|
checkId: "fs.auth_profiles.perms_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "auth-profiles.json is readable by others",
|
title: "auth-profiles.json is readable by others",
|
||||||
detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
||||||
remediation: `chmod 600 ${authPath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: authPath,
|
||||||
|
perms: authPerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
|
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const storeStat = await safeStat(storePath);
|
const storePerms = await inspectPathPermissions(storePath, {
|
||||||
if (storeStat.ok) {
|
env: params.env,
|
||||||
const bits = modeBits(storeStat.mode);
|
platform: params.platform,
|
||||||
if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (storePerms.ok) {
|
||||||
|
if (storePerms.worldReadable || storePerms.groupReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.sessions_store.perms_readable",
|
checkId: "fs.sessions_store.perms_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "sessions.json is readable by others",
|
title: "sessions.json is readable by others",
|
||||||
detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
|
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
|
||||||
remediation: `chmod 600 ${storePath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: storePath,
|
||||||
|
perms: storePerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: {
|
|||||||
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
|
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
const logPath = path.resolve(expanded);
|
const logPath = path.resolve(expanded);
|
||||||
const st = await safeStat(logPath);
|
const logPerms = await inspectPathPermissions(logPath, {
|
||||||
if (st.ok) {
|
env: params.env,
|
||||||
const bits = modeBits(st.mode);
|
platform: params.platform,
|
||||||
if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (logPerms.ok) {
|
||||||
|
if (logPerms.worldReadable || logPerms.groupReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.log_file.perms_readable",
|
checkId: "fs.log_file.perms_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "Log file is readable by others",
|
title: "Log file is readable by others",
|
||||||
detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
|
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
|
||||||
remediation: `chmod 600 ${logPath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: logPath,
|
||||||
|
perms: logPerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatIcaclsResetCommand,
|
||||||
|
formatWindowsAclSummary,
|
||||||
|
inspectWindowsAcl,
|
||||||
|
type ExecFn,
|
||||||
|
} from "./windows-acl.js";
|
||||||
|
|
||||||
|
export type PermissionCheck = {
|
||||||
|
ok: boolean;
|
||||||
|
isSymlink: boolean;
|
||||||
|
isDir: boolean;
|
||||||
|
mode: number | null;
|
||||||
|
bits: number | null;
|
||||||
|
source: "posix" | "windows-acl" | "unknown";
|
||||||
|
worldWritable: boolean;
|
||||||
|
groupWritable: boolean;
|
||||||
|
worldReadable: boolean;
|
||||||
|
groupReadable: boolean;
|
||||||
|
aclSummary?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionCheckOptions = {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
exec?: ExecFn;
|
||||||
|
};
|
||||||
|
|
||||||
export async function safeStat(targetPath: string): Promise<{
|
export async function safeStat(targetPath: string): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
isSymlink: boolean;
|
isSymlink: boolean;
|
||||||
@@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function inspectPathPermissions(
|
||||||
|
targetPath: string,
|
||||||
|
opts?: PermissionCheckOptions,
|
||||||
|
): Promise<PermissionCheck> {
|
||||||
|
const st = await safeStat(targetPath);
|
||||||
|
if (!st.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
isSymlink: false,
|
||||||
|
isDir: false,
|
||||||
|
mode: null,
|
||||||
|
bits: null,
|
||||||
|
source: "unknown",
|
||||||
|
worldWritable: false,
|
||||||
|
groupWritable: false,
|
||||||
|
worldReadable: false,
|
||||||
|
groupReadable: false,
|
||||||
|
error: st.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bits = modeBits(st.mode);
|
||||||
|
const platform = opts?.platform ?? process.platform;
|
||||||
|
|
||||||
|
if (platform === "win32") {
|
||||||
|
const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
|
||||||
|
if (!acl.ok) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isSymlink: st.isSymlink,
|
||||||
|
isDir: st.isDir,
|
||||||
|
mode: st.mode,
|
||||||
|
bits,
|
||||||
|
source: "unknown",
|
||||||
|
worldWritable: false,
|
||||||
|
groupWritable: false,
|
||||||
|
worldReadable: false,
|
||||||
|
groupReadable: false,
|
||||||
|
error: acl.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isSymlink: st.isSymlink,
|
||||||
|
isDir: st.isDir,
|
||||||
|
mode: st.mode,
|
||||||
|
bits,
|
||||||
|
source: "windows-acl",
|
||||||
|
worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
|
||||||
|
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
|
||||||
|
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
|
||||||
|
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
|
||||||
|
aclSummary: formatWindowsAclSummary(acl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isSymlink: st.isSymlink,
|
||||||
|
isDir: st.isDir,
|
||||||
|
mode: st.mode,
|
||||||
|
bits,
|
||||||
|
source: "posix",
|
||||||
|
worldWritable: isWorldWritable(bits),
|
||||||
|
groupWritable: isGroupWritable(bits),
|
||||||
|
worldReadable: isWorldReadable(bits),
|
||||||
|
groupReadable: isGroupReadable(bits),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
|
||||||
|
if (perms.source === "windows-acl") {
|
||||||
|
const summary = perms.aclSummary ?? "unknown";
|
||||||
|
return `${targetPath} acl=${summary}`;
|
||||||
|
}
|
||||||
|
return `${targetPath} mode=${formatOctal(perms.bits)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPermissionRemediation(params: {
|
||||||
|
targetPath: string;
|
||||||
|
perms: PermissionCheck;
|
||||||
|
isDir: boolean;
|
||||||
|
posixMode: number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): string {
|
||||||
|
if (params.perms.source === "windows-acl") {
|
||||||
|
return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
|
||||||
|
}
|
||||||
|
const mode = params.posixMode.toString(8).padStart(3, "0");
|
||||||
|
return `chmod ${mode} ${params.targetPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function modeBits(mode: number | null): number | null {
|
export function modeBits(mode: number | null): number | null {
|
||||||
if (mode == null) return null;
|
if (mode == null) return null;
|
||||||
return mode & 0o777;
|
return mode & 0o777;
|
||||||
|
|||||||
@@ -120,6 +120,83 @@ describe("security audit", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats Windows ACL-only perms as secure", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-"));
|
||||||
|
const stateDir = path.join(tmp, "state");
|
||||||
|
await fs.mkdir(stateDir, { recursive: true });
|
||||||
|
const configPath = path.join(stateDir, "clawdbot.json");
|
||||||
|
await fs.writeFile(configPath, "{}\n", "utf-8");
|
||||||
|
|
||||||
|
const user = "DESKTOP-TEST\\Tester";
|
||||||
|
const execIcacls = async (_cmd: string, args: string[]) => ({
|
||||||
|
stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: {},
|
||||||
|
includeFilesystem: true,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
stateDir,
|
||||||
|
configPath,
|
||||||
|
platform: "win32",
|
||||||
|
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
|
||||||
|
execIcacls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const forbidden = new Set([
|
||||||
|
"fs.state_dir.perms_world_writable",
|
||||||
|
"fs.state_dir.perms_group_writable",
|
||||||
|
"fs.state_dir.perms_readable",
|
||||||
|
"fs.config.perms_writable",
|
||||||
|
"fs.config.perms_world_readable",
|
||||||
|
"fs.config.perms_group_readable",
|
||||||
|
]);
|
||||||
|
for (const id of forbidden) {
|
||||||
|
expect(res.findings.some((f) => f.checkId === id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags Windows ACLs when Users can read the state dir", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-open-"));
|
||||||
|
const stateDir = path.join(tmp, "state");
|
||||||
|
await fs.mkdir(stateDir, { recursive: true });
|
||||||
|
const configPath = path.join(stateDir, "clawdbot.json");
|
||||||
|
await fs.writeFile(configPath, "{}\n", "utf-8");
|
||||||
|
|
||||||
|
const user = "DESKTOP-TEST\\Tester";
|
||||||
|
const execIcacls = async (_cmd: string, args: string[]) => {
|
||||||
|
const target = args[0];
|
||||||
|
if (target === stateDir) {
|
||||||
|
return {
|
||||||
|
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`,
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: {},
|
||||||
|
includeFilesystem: true,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
stateDir,
|
||||||
|
configPath,
|
||||||
|
platform: "win32",
|
||||||
|
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
|
||||||
|
execIcacls,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
res.findings.some(
|
||||||
|
(f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("warns when small models are paired with web/browser tools", async () => {
|
it("warns when small models are paired with web/browser tools", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
|
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
|
||||||
|
|||||||
@@ -24,14 +24,11 @@ import {
|
|||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
|
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||||
import {
|
import {
|
||||||
formatOctal,
|
formatPermissionDetail,
|
||||||
isGroupReadable,
|
formatPermissionRemediation,
|
||||||
isGroupWritable,
|
inspectPathPermissions,
|
||||||
isWorldReadable,
|
|
||||||
isWorldWritable,
|
|
||||||
modeBits,
|
|
||||||
safeStat,
|
|
||||||
} from "./audit-fs.js";
|
} from "./audit-fs.js";
|
||||||
|
import type { ExecFn } from "./windows-acl.js";
|
||||||
|
|
||||||
export type SecurityAuditSeverity = "info" | "warn" | "critical";
|
export type SecurityAuditSeverity = "info" | "warn" | "critical";
|
||||||
|
|
||||||
@@ -66,6 +63,8 @@ export type SecurityAuditReport = {
|
|||||||
|
|
||||||
export type SecurityAuditOptions = {
|
export type SecurityAuditOptions = {
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
deep?: boolean;
|
deep?: boolean;
|
||||||
includeFilesystem?: boolean;
|
includeFilesystem?: boolean;
|
||||||
includeChannelSecurity?: boolean;
|
includeChannelSecurity?: boolean;
|
||||||
@@ -79,6 +78,8 @@ export type SecurityAuditOptions = {
|
|||||||
plugins?: ReturnType<typeof listChannelPlugins>;
|
plugins?: ReturnType<typeof listChannelPlugins>;
|
||||||
/** Dependency injection for tests. */
|
/** Dependency injection for tests. */
|
||||||
probeGatewayFn?: typeof probeGateway;
|
probeGatewayFn?: typeof probeGateway;
|
||||||
|
/** Dependency injection for tests (Windows ACL checks). */
|
||||||
|
execIcacls?: ExecFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
|
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
|
||||||
@@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
|
|||||||
async function collectFilesystemFindings(params: {
|
async function collectFilesystemFindings(params: {
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
execIcacls?: ExecFn;
|
||||||
}): Promise<SecurityAuditFinding[]> {
|
}): Promise<SecurityAuditFinding[]> {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
|
|
||||||
const stateDirStat = await safeStat(params.stateDir);
|
const stateDirPerms = await inspectPathPermissions(params.stateDir, {
|
||||||
if (stateDirStat.ok) {
|
env: params.env,
|
||||||
const bits = modeBits(stateDirStat.mode);
|
platform: params.platform,
|
||||||
if (stateDirStat.isSymlink) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (stateDirPerms.ok) {
|
||||||
|
if (stateDirPerms.isSymlink) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.state_dir.symlink",
|
checkId: "fs.state_dir.symlink",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
@@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: {
|
|||||||
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
|
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isWorldWritable(bits)) {
|
if (stateDirPerms.worldWritable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.state_dir.perms_world_writable",
|
checkId: "fs.state_dir.perms_world_writable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "State dir is world-writable",
|
title: "State dir is world-writable",
|
||||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`,
|
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`,
|
||||||
remediation: `chmod 700 ${params.stateDir}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: params.stateDir,
|
||||||
|
perms: stateDirPerms,
|
||||||
|
isDir: true,
|
||||||
|
posixMode: 0o700,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isGroupWritable(bits)) {
|
} else if (stateDirPerms.groupWritable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.state_dir.perms_group_writable",
|
checkId: "fs.state_dir.perms_group_writable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "State dir is group-writable",
|
title: "State dir is group-writable",
|
||||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`,
|
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`,
|
||||||
remediation: `chmod 700 ${params.stateDir}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: params.stateDir,
|
||||||
|
perms: stateDirPerms,
|
||||||
|
isDir: true,
|
||||||
|
posixMode: 0o700,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
|
} else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.state_dir.perms_readable",
|
checkId: "fs.state_dir.perms_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "State dir is readable by others",
|
title: "State dir is readable by others",
|
||||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`,
|
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
|
||||||
remediation: `chmod 700 ${params.stateDir}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: params.stateDir,
|
||||||
|
perms: stateDirPerms,
|
||||||
|
isDir: true,
|
||||||
|
posixMode: 0o700,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const configStat = await safeStat(params.configPath);
|
const configPerms = await inspectPathPermissions(params.configPath, {
|
||||||
if (configStat.ok) {
|
env: params.env,
|
||||||
const bits = modeBits(configStat.mode);
|
platform: params.platform,
|
||||||
if (configStat.isSymlink) {
|
exec: params.execIcacls,
|
||||||
|
});
|
||||||
|
if (configPerms.ok) {
|
||||||
|
if (configPerms.isSymlink) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config.symlink",
|
checkId: "fs.config.symlink",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
@@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: {
|
|||||||
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
|
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
if (configPerms.worldWritable || configPerms.groupWritable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config.perms_writable",
|
checkId: "fs.config.perms_writable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "Config file is writable by others",
|
title: "Config file is writable by others",
|
||||||
detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`,
|
detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
|
||||||
remediation: `chmod 600 ${params.configPath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: params.configPath,
|
||||||
|
perms: configPerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isWorldReadable(bits)) {
|
} else if (configPerms.worldReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config.perms_world_readable",
|
checkId: "fs.config.perms_world_readable",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: "Config file is world-readable",
|
title: "Config file is world-readable",
|
||||||
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
|
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
||||||
remediation: `chmod 600 ${params.configPath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: params.configPath,
|
||||||
|
perms: configPerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} else if (isGroupReadable(bits)) {
|
} else if (configPerms.groupReadable) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "fs.config.perms_group_readable",
|
checkId: "fs.config.perms_group_readable",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "Config file is group-readable",
|
title: "Config file is group-readable",
|
||||||
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
|
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
||||||
remediation: `chmod 600 ${params.configPath}`,
|
remediation: formatPermissionRemediation({
|
||||||
|
targetPath: params.configPath,
|
||||||
|
perms: configPerms,
|
||||||
|
isDir: false,
|
||||||
|
posixMode: 0o600,
|
||||||
|
env: params.env,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,7 +896,9 @@ async function maybeProbeGateway(params: {
|
|||||||
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
|
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
const cfg = opts.config;
|
const cfg = opts.config;
|
||||||
const env = process.env;
|
const env = opts.env ?? process.env;
|
||||||
|
const platform = opts.platform ?? process.platform;
|
||||||
|
const execIcacls = opts.execIcacls;
|
||||||
const stateDir = opts.stateDir ?? resolveStateDir(env);
|
const stateDir = opts.stateDir ?? resolveStateDir(env);
|
||||||
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
|
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
|
||||||
|
|
||||||
@@ -873,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (opts.includeFilesystem !== false) {
|
if (opts.includeFilesystem !== false) {
|
||||||
findings.push(...(await collectFilesystemFindings({ stateDir, configPath })));
|
findings.push(
|
||||||
|
...(await collectFilesystemFindings({
|
||||||
|
stateDir,
|
||||||
|
configPath,
|
||||||
|
env,
|
||||||
|
platform,
|
||||||
|
execIcacls,
|
||||||
|
})),
|
||||||
|
);
|
||||||
if (configSnapshot) {
|
if (configSnapshot) {
|
||||||
findings.push(...(await collectIncludeFilePermFindings({ configSnapshot })));
|
findings.push(
|
||||||
|
...(await collectIncludeFilePermFindings({ configSnapshot, env, platform, execIcacls })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir })));
|
findings.push(
|
||||||
|
...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })),
|
||||||
|
);
|
||||||
findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
|
findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|||||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||||
|
import { runExec } from "../process/exec.js";
|
||||||
|
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
|
||||||
|
|
||||||
export type SecurityFixChmodAction = {
|
export type SecurityFixChmodAction = {
|
||||||
kind: "chmod";
|
kind: "chmod";
|
||||||
@@ -20,13 +22,24 @@ export type SecurityFixChmodAction = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SecurityFixIcaclsAction = {
|
||||||
|
kind: "icacls";
|
||||||
|
path: string;
|
||||||
|
command: string;
|
||||||
|
ok: boolean;
|
||||||
|
skipped?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
|
||||||
|
|
||||||
export type SecurityFixResult = {
|
export type SecurityFixResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
configWritten: boolean;
|
configWritten: boolean;
|
||||||
changes: string[];
|
changes: string[];
|
||||||
actions: SecurityFixChmodAction[];
|
actions: SecurityFixAction[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,6 +110,82 @@ async function safeChmod(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function safeAclReset(params: {
|
||||||
|
path: string;
|
||||||
|
require: "dir" | "file";
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
exec?: ExecFn;
|
||||||
|
}): Promise<SecurityFixIcaclsAction> {
|
||||||
|
const display = formatIcaclsResetCommand(params.path, {
|
||||||
|
isDir: params.require === "dir",
|
||||||
|
env: params.env,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const st = await fs.lstat(params.path);
|
||||||
|
if (st.isSymbolicLink()) {
|
||||||
|
return {
|
||||||
|
kind: "icacls",
|
||||||
|
path: params.path,
|
||||||
|
command: display,
|
||||||
|
ok: false,
|
||||||
|
skipped: "symlink",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (params.require === "dir" && !st.isDirectory()) {
|
||||||
|
return {
|
||||||
|
kind: "icacls",
|
||||||
|
path: params.path,
|
||||||
|
command: display,
|
||||||
|
ok: false,
|
||||||
|
skipped: "not-a-directory",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (params.require === "file" && !st.isFile()) {
|
||||||
|
return {
|
||||||
|
kind: "icacls",
|
||||||
|
path: params.path,
|
||||||
|
command: display,
|
||||||
|
ok: false,
|
||||||
|
skipped: "not-a-file",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cmd = createIcaclsResetCommand(params.path, {
|
||||||
|
isDir: st.isDirectory(),
|
||||||
|
env: params.env,
|
||||||
|
});
|
||||||
|
if (!cmd) {
|
||||||
|
return {
|
||||||
|
kind: "icacls",
|
||||||
|
path: params.path,
|
||||||
|
command: display,
|
||||||
|
ok: false,
|
||||||
|
skipped: "missing-user",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const exec = params.exec ?? runExec;
|
||||||
|
await exec(cmd.command, cmd.args);
|
||||||
|
return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
kind: "icacls",
|
||||||
|
path: params.path,
|
||||||
|
command: display,
|
||||||
|
ok: false,
|
||||||
|
skipped: "missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "icacls",
|
||||||
|
path: params.path,
|
||||||
|
command: display,
|
||||||
|
ok: false,
|
||||||
|
error: String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setGroupPolicyAllowlist(params: {
|
function setGroupPolicyAllowlist(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
actions: SecurityFixChmodAction[];
|
actions: SecurityFixAction[];
|
||||||
|
applyPerms: (params: {
|
||||||
|
path: string;
|
||||||
|
mode: number;
|
||||||
|
require: "dir" | "file";
|
||||||
|
}) => Promise<SecurityFixAction>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const credsDir = resolveOAuthDir(params.env, params.stateDir);
|
const credsDir = resolveOAuthDir(params.env, params.stateDir);
|
||||||
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
|
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
|
||||||
@@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: {
|
|||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
|
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" }));
|
params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" }));
|
||||||
|
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" }));
|
params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" }));
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" }));
|
params.actions.push(
|
||||||
|
await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }),
|
||||||
|
);
|
||||||
|
|
||||||
const storePath = path.join(sessionsDir, "sessions.json");
|
const storePath = path.join(sessionsDir, "sessions.json");
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" }));
|
params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: {
|
|||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
stateDir?: string;
|
stateDir?: string;
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
exec?: ExecFn;
|
||||||
}): Promise<SecurityFixResult> {
|
}): Promise<SecurityFixResult> {
|
||||||
const env = opts?.env ?? process.env;
|
const env = opts?.env ?? process.env;
|
||||||
|
const platform = opts?.platform ?? process.platform;
|
||||||
|
const exec = opts?.exec ?? runExec;
|
||||||
|
const isWindows = platform === "win32";
|
||||||
const stateDir = opts?.stateDir ?? resolveStateDir(env);
|
const stateDir = opts?.stateDir ?? resolveStateDir(env);
|
||||||
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
|
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
|
||||||
const actions: SecurityFixChmodAction[] = [];
|
const actions: SecurityFixAction[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
const io = createConfigIO({ env, configPath });
|
const io = createConfigIO({ env, configPath });
|
||||||
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
|
const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
|
||||||
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
|
isWindows
|
||||||
|
? safeAclReset({ path: params.path, require: params.require, env, exec })
|
||||||
|
: safeChmod({ path: params.path, mode: params.mode, require: params.require });
|
||||||
|
|
||||||
|
actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" }));
|
||||||
|
actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" }));
|
||||||
|
|
||||||
if (snap.exists) {
|
if (snap.exists) {
|
||||||
const includePaths = await collectIncludePathsRecursive({
|
const includePaths = await collectIncludePathsRecursive({
|
||||||
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
|
|||||||
}).catch(() => []);
|
}).catch(() => []);
|
||||||
for (const p of includePaths) {
|
for (const p of includePaths) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
|
actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch(
|
await chmodCredentialsAndAgentState({
|
||||||
(err) => {
|
env,
|
||||||
|
stateDir,
|
||||||
|
cfg: snap.config ?? {},
|
||||||
|
actions,
|
||||||
|
applyPerms,
|
||||||
|
}).catch((err) => {
|
||||||
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
|
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: errors.length === 0,
|
ok: errors.length === 0,
|
||||||
|
|||||||
203
src/security/windows-acl.ts
Normal file
203
src/security/windows-acl.ts
Normal 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) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user