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: 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
formatOctal,
|
||||
isGroupReadable,
|
||||
isGroupWritable,
|
||||
isWorldReadable,
|
||||
isWorldWritable,
|
||||
modeBits,
|
||||
formatPermissionDetail,
|
||||
formatPermissionRemediation,
|
||||
inspectPathPermissions,
|
||||
safeStat,
|
||||
} from "./audit-fs.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
|
||||
|
||||
export async function collectIncludeFilePermFindings(params: {
|
||||
configSnapshot: ConfigFileSnapshot;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (!params.configSnapshot.exists) return findings;
|
||||
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
|
||||
|
||||
for (const p of includePaths) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const st = await safeStat(p);
|
||||
if (!st.ok) continue;
|
||||
const bits = modeBits(st.mode);
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
const perms = await inspectPathPermissions(p, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (!perms.ok) continue;
|
||||
if (perms.worldWritable || perms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Config include file is writable by others",
|
||||
detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
|
||||
remediation: `chmod 600 ${p}`,
|
||||
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isWorldReadable(bits)) {
|
||||
} else if (perms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_world_readable",
|
||||
severity: "critical",
|
||||
title: "Config include file is world-readable",
|
||||
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${p}`,
|
||||
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits)) {
|
||||
} else if (perms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_group_readable",
|
||||
severity: "warn",
|
||||
title: "Config include file is group-readable",
|
||||
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${p}`,
|
||||
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
|
||||
|
||||
const oauthStat = await safeStat(oauthDir);
|
||||
if (oauthStat.ok && oauthStat.isDir) {
|
||||
const bits = modeBits(oauthStat.mode);
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
const oauthPerms = await inspectPathPermissions(oauthDir, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (oauthPerms.ok && oauthPerms.isDir) {
|
||||
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.credentials_dir.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Credentials dir is writable by others",
|
||||
detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
|
||||
remediation: `chmod 700 ${oauthDir}`,
|
||||
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: oauthDir,
|
||||
perms: oauthPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
|
||||
} else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.credentials_dir.perms_readable",
|
||||
severity: "warn",
|
||||
title: "Credentials dir is readable by others",
|
||||
detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
|
||||
remediation: `chmod 700 ${oauthDir}`,
|
||||
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: oauthDir,
|
||||
perms: oauthPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: {
|
||||
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const authStat = await safeStat(authPath);
|
||||
if (authStat.ok) {
|
||||
const bits = modeBits(authStat.mode);
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
const authPerms = await inspectPathPermissions(authPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (authPerms.ok) {
|
||||
if (authPerms.worldWritable || authPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.auth_profiles.perms_writable",
|
||||
severity: "critical",
|
||||
title: "auth-profiles.json is writable by others",
|
||||
detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
|
||||
remediation: `chmod 600 ${authPath}`,
|
||||
detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: authPath,
|
||||
perms: authPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
||||
} else if (authPerms.worldReadable || authPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.auth_profiles.perms_readable",
|
||||
severity: "warn",
|
||||
title: "auth-profiles.json is readable by others",
|
||||
detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
||||
remediation: `chmod 600 ${authPath}`,
|
||||
detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: authPath,
|
||||
perms: authPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const storeStat = await safeStat(storePath);
|
||||
if (storeStat.ok) {
|
||||
const bits = modeBits(storeStat.mode);
|
||||
if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
||||
const storePerms = await inspectPathPermissions(storePath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (storePerms.ok) {
|
||||
if (storePerms.worldReadable || storePerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.sessions_store.perms_readable",
|
||||
severity: "warn",
|
||||
title: "sessions.json is readable by others",
|
||||
detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
|
||||
remediation: `chmod 600 ${storePath}`,
|
||||
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: storePath,
|
||||
perms: storePerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: {
|
||||
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
|
||||
if (expanded) {
|
||||
const logPath = path.resolve(expanded);
|
||||
const st = await safeStat(logPath);
|
||||
if (st.ok) {
|
||||
const bits = modeBits(st.mode);
|
||||
if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
||||
const logPerms = await inspectPathPermissions(logPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (logPerms.ok) {
|
||||
if (logPerms.worldReadable || logPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.log_file.perms_readable",
|
||||
severity: "warn",
|
||||
title: "Log file is readable by others",
|
||||
detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
|
||||
remediation: `chmod 600 ${logPath}`,
|
||||
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: logPath,
|
||||
perms: logPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
formatIcaclsResetCommand,
|
||||
formatWindowsAclSummary,
|
||||
inspectWindowsAcl,
|
||||
type ExecFn,
|
||||
} from "./windows-acl.js";
|
||||
|
||||
export type PermissionCheck = {
|
||||
ok: boolean;
|
||||
isSymlink: boolean;
|
||||
isDir: boolean;
|
||||
mode: number | null;
|
||||
bits: number | null;
|
||||
source: "posix" | "windows-acl" | "unknown";
|
||||
worldWritable: boolean;
|
||||
groupWritable: boolean;
|
||||
worldReadable: boolean;
|
||||
groupReadable: boolean;
|
||||
aclSummary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type PermissionCheckOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
exec?: ExecFn;
|
||||
};
|
||||
|
||||
export async function safeStat(targetPath: string): Promise<{
|
||||
ok: boolean;
|
||||
isSymlink: boolean;
|
||||
@@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectPathPermissions(
|
||||
targetPath: string,
|
||||
opts?: PermissionCheckOptions,
|
||||
): Promise<PermissionCheck> {
|
||||
const st = await safeStat(targetPath);
|
||||
if (!st.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
isSymlink: false,
|
||||
isDir: false,
|
||||
mode: null,
|
||||
bits: null,
|
||||
source: "unknown",
|
||||
worldWritable: false,
|
||||
groupWritable: false,
|
||||
worldReadable: false,
|
||||
groupReadable: false,
|
||||
error: st.error,
|
||||
};
|
||||
}
|
||||
|
||||
const bits = modeBits(st.mode);
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
|
||||
if (!acl.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
isSymlink: st.isSymlink,
|
||||
isDir: st.isDir,
|
||||
mode: st.mode,
|
||||
bits,
|
||||
source: "unknown",
|
||||
worldWritable: false,
|
||||
groupWritable: false,
|
||||
worldReadable: false,
|
||||
groupReadable: false,
|
||||
error: acl.error,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
isSymlink: st.isSymlink,
|
||||
isDir: st.isDir,
|
||||
mode: st.mode,
|
||||
bits,
|
||||
source: "windows-acl",
|
||||
worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
|
||||
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
|
||||
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
|
||||
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
|
||||
aclSummary: formatWindowsAclSummary(acl),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
isSymlink: st.isSymlink,
|
||||
isDir: st.isDir,
|
||||
mode: st.mode,
|
||||
bits,
|
||||
source: "posix",
|
||||
worldWritable: isWorldWritable(bits),
|
||||
groupWritable: isGroupWritable(bits),
|
||||
worldReadable: isWorldReadable(bits),
|
||||
groupReadable: isGroupReadable(bits),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
|
||||
if (perms.source === "windows-acl") {
|
||||
const summary = perms.aclSummary ?? "unknown";
|
||||
return `${targetPath} acl=${summary}`;
|
||||
}
|
||||
return `${targetPath} mode=${formatOctal(perms.bits)}`;
|
||||
}
|
||||
|
||||
export function formatPermissionRemediation(params: {
|
||||
targetPath: string;
|
||||
perms: PermissionCheck;
|
||||
isDir: boolean;
|
||||
posixMode: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
if (params.perms.source === "windows-acl") {
|
||||
return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
|
||||
}
|
||||
const mode = params.posixMode.toString(8).padStart(3, "0");
|
||||
return `chmod ${mode} ${params.targetPath}`;
|
||||
}
|
||||
|
||||
export function modeBits(mode: number | null): number | null {
|
||||
if (mode == null) return null;
|
||||
return mode & 0o777;
|
||||
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -24,14 +24,11 @@ import {
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import {
|
||||
formatOctal,
|
||||
isGroupReadable,
|
||||
isGroupWritable,
|
||||
isWorldReadable,
|
||||
isWorldWritable,
|
||||
modeBits,
|
||||
safeStat,
|
||||
formatPermissionDetail,
|
||||
formatPermissionRemediation,
|
||||
inspectPathPermissions,
|
||||
} from "./audit-fs.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityAuditSeverity = "info" | "warn" | "critical";
|
||||
|
||||
@@ -66,6 +63,8 @@ export type SecurityAuditReport = {
|
||||
|
||||
export type SecurityAuditOptions = {
|
||||
config: ClawdbotConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
deep?: boolean;
|
||||
includeFilesystem?: boolean;
|
||||
includeChannelSecurity?: boolean;
|
||||
@@ -79,6 +78,8 @@ export type SecurityAuditOptions = {
|
||||
plugins?: ReturnType<typeof listChannelPlugins>;
|
||||
/** Dependency injection for tests. */
|
||||
probeGatewayFn?: typeof probeGateway;
|
||||
/** Dependency injection for tests (Windows ACL checks). */
|
||||
execIcacls?: ExecFn;
|
||||
};
|
||||
|
||||
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
|
||||
@@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
|
||||
async function collectFilesystemFindings(params: {
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
|
||||
const stateDirStat = await safeStat(params.stateDir);
|
||||
if (stateDirStat.ok) {
|
||||
const bits = modeBits(stateDirStat.mode);
|
||||
if (stateDirStat.isSymlink) {
|
||||
const stateDirPerms = await inspectPathPermissions(params.stateDir, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (stateDirPerms.ok) {
|
||||
if (stateDirPerms.isSymlink) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.symlink",
|
||||
severity: "warn",
|
||||
@@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: {
|
||||
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
|
||||
});
|
||||
}
|
||||
if (isWorldWritable(bits)) {
|
||||
if (stateDirPerms.worldWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.perms_world_writable",
|
||||
severity: "critical",
|
||||
title: "State dir is world-writable",
|
||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`,
|
||||
remediation: `chmod 700 ${params.stateDir}`,
|
||||
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.stateDir,
|
||||
perms: stateDirPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupWritable(bits)) {
|
||||
} else if (stateDirPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.perms_group_writable",
|
||||
severity: "warn",
|
||||
title: "State dir is group-writable",
|
||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`,
|
||||
remediation: `chmod 700 ${params.stateDir}`,
|
||||
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.stateDir,
|
||||
perms: stateDirPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
|
||||
} else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.perms_readable",
|
||||
severity: "warn",
|
||||
title: "State dir is readable by others",
|
||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`,
|
||||
remediation: `chmod 700 ${params.stateDir}`,
|
||||
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.stateDir,
|
||||
perms: stateDirPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configStat = await safeStat(params.configPath);
|
||||
if (configStat.ok) {
|
||||
const bits = modeBits(configStat.mode);
|
||||
if (configStat.isSymlink) {
|
||||
const configPerms = await inspectPathPermissions(params.configPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (configPerms.ok) {
|
||||
if (configPerms.isSymlink) {
|
||||
findings.push({
|
||||
checkId: "fs.config.symlink",
|
||||
severity: "warn",
|
||||
@@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: {
|
||||
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
|
||||
});
|
||||
}
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
if (configPerms.worldWritable || configPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.config.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Config file is writable by others",
|
||||
detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`,
|
||||
remediation: `chmod 600 ${params.configPath}`,
|
||||
detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.configPath,
|
||||
perms: configPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isWorldReadable(bits)) {
|
||||
} else if (configPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config.perms_world_readable",
|
||||
severity: "critical",
|
||||
title: "Config file is world-readable",
|
||||
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${params.configPath}`,
|
||||
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.configPath,
|
||||
perms: configPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits)) {
|
||||
} else if (configPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config.perms_group_readable",
|
||||
severity: "warn",
|
||||
title: "Config file is group-readable",
|
||||
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${params.configPath}`,
|
||||
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.configPath,
|
||||
perms: configPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -850,7 +896,9 @@ async function maybeProbeGateway(params: {
|
||||
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const cfg = opts.config;
|
||||
const env = process.env;
|
||||
const env = opts.env ?? process.env;
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const execIcacls = opts.execIcacls;
|
||||
const stateDir = opts.stateDir ?? resolveStateDir(env);
|
||||
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
|
||||
|
||||
@@ -873,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
: null;
|
||||
|
||||
if (opts.includeFilesystem !== false) {
|
||||
findings.push(...(await collectFilesystemFindings({ stateDir, configPath })));
|
||||
findings.push(
|
||||
...(await collectFilesystemFindings({
|
||||
stateDir,
|
||||
configPath,
|
||||
env,
|
||||
platform,
|
||||
execIcacls,
|
||||
})),
|
||||
);
|
||||
if (configSnapshot) {
|
||||
findings.push(...(await collectIncludeFilePermFindings({ configSnapshot })));
|
||||
findings.push(
|
||||
...(await collectIncludeFilePermFindings({ configSnapshot, env, platform, execIcacls })),
|
||||
);
|
||||
}
|
||||
findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir })));
|
||||
findings.push(
|
||||
...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })),
|
||||
);
|
||||
findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityFixChmodAction = {
|
||||
kind: "chmod";
|
||||
@@ -20,13 +22,24 @@ export type SecurityFixChmodAction = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SecurityFixIcaclsAction = {
|
||||
kind: "icacls";
|
||||
path: string;
|
||||
command: string;
|
||||
ok: boolean;
|
||||
skipped?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
|
||||
|
||||
export type SecurityFixResult = {
|
||||
ok: boolean;
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
configWritten: boolean;
|
||||
changes: string[];
|
||||
actions: SecurityFixChmodAction[];
|
||||
actions: SecurityFixAction[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
@@ -97,6 +110,82 @@ async function safeChmod(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function safeAclReset(params: {
|
||||
path: string;
|
||||
require: "dir" | "file";
|
||||
env: NodeJS.ProcessEnv;
|
||||
exec?: ExecFn;
|
||||
}): Promise<SecurityFixIcaclsAction> {
|
||||
const display = formatIcaclsResetCommand(params.path, {
|
||||
isDir: params.require === "dir",
|
||||
env: params.env,
|
||||
});
|
||||
try {
|
||||
const st = await fs.lstat(params.path);
|
||||
if (st.isSymbolicLink()) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "symlink",
|
||||
};
|
||||
}
|
||||
if (params.require === "dir" && !st.isDirectory()) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "not-a-directory",
|
||||
};
|
||||
}
|
||||
if (params.require === "file" && !st.isFile()) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "not-a-file",
|
||||
};
|
||||
}
|
||||
const cmd = createIcaclsResetCommand(params.path, {
|
||||
isDir: st.isDirectory(),
|
||||
env: params.env,
|
||||
});
|
||||
if (!cmd) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "missing-user",
|
||||
};
|
||||
}
|
||||
const exec = params.exec ?? runExec;
|
||||
await exec(cmd.command, cmd.args);
|
||||
return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "missing",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
error: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function setGroupPolicyAllowlist(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: string;
|
||||
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
cfg: ClawdbotConfig;
|
||||
actions: SecurityFixChmodAction[];
|
||||
actions: SecurityFixAction[];
|
||||
applyPerms: (params: {
|
||||
path: string;
|
||||
mode: number;
|
||||
require: "dir" | "file";
|
||||
}) => Promise<SecurityFixAction>;
|
||||
}): Promise<void> {
|
||||
const credsDir = resolveOAuthDir(params.env, params.stateDir);
|
||||
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
|
||||
@@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" }));
|
||||
params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" }));
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" }));
|
||||
params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" }));
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" }));
|
||||
params.actions.push(
|
||||
await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }),
|
||||
);
|
||||
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" }));
|
||||
params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
configPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
exec?: ExecFn;
|
||||
}): Promise<SecurityFixResult> {
|
||||
const env = opts?.env ?? process.env;
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
const exec = opts?.exec ?? runExec;
|
||||
const isWindows = platform === "win32";
|
||||
const stateDir = opts?.stateDir ?? resolveStateDir(env);
|
||||
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
|
||||
const actions: SecurityFixChmodAction[] = [];
|
||||
const actions: SecurityFixAction[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const io = createConfigIO({ env, configPath });
|
||||
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
|
||||
}
|
||||
}
|
||||
|
||||
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
|
||||
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
|
||||
const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
|
||||
isWindows
|
||||
? safeAclReset({ path: params.path, require: params.require, env, exec })
|
||||
: safeChmod({ path: params.path, mode: params.mode, require: params.require });
|
||||
|
||||
actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" }));
|
||||
actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" }));
|
||||
|
||||
if (snap.exists) {
|
||||
const includePaths = await collectIncludePathsRecursive({
|
||||
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
|
||||
}).catch(() => []);
|
||||
for (const p of includePaths) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
|
||||
actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" }));
|
||||
}
|
||||
}
|
||||
|
||||
await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch(
|
||||
(err) => {
|
||||
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
|
||||
},
|
||||
);
|
||||
await chmodCredentialsAndAgentState({
|
||||
env,
|
||||
stateDir,
|
||||
cfg: snap.config ?? {},
|
||||
actions,
|
||||
applyPerms,
|
||||
}).catch((err) => {
|
||||
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
|
||||
203
src/security/windows-acl.ts
Normal file
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