mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 06:50:22 +00:00
test: merge audit extension and workspace cases
This commit is contained in:
@@ -250,6 +250,17 @@ describe("security audit", () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runSharedExtensionsAudit = async (config: OpenClawConfig) => {
|
||||||
|
return runSecurityAudit({
|
||||||
|
config,
|
||||||
|
includeFilesystem: true,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
stateDir: sharedExtensionsStateDir,
|
||||||
|
configPath: path.join(sharedExtensionsStateDir, "openclaw.json"),
|
||||||
|
execDockerRawFn: execDockerRawUnavailable,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const createSharedCodeSafetyFixture = async () => {
|
const createSharedCodeSafetyFixture = async () => {
|
||||||
const stateDir = await makeTmpDir("audit-scanner-shared");
|
const stateDir = await makeTmpDir("audit-scanner-shared");
|
||||||
const workspaceDir = path.join(stateDir, "workspace");
|
const workspaceDir = path.join(stateDir, "workspace");
|
||||||
@@ -642,52 +653,64 @@ description: test skill
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns for risky safeBinTrustedDirs entries", async () => {
|
it("evaluates safeBinTrustedDirs risk findings", async () => {
|
||||||
const riskyGlobalTrustedDirs =
|
const riskyGlobalTrustedDirs =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`]
|
? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`]
|
||||||
: ["/usr/local/bin", "/tmp/openclaw-safe-bins"];
|
: ["/usr/local/bin", "/tmp/openclaw-safe-bins"];
|
||||||
const cfg: OpenClawConfig = {
|
const cases = [
|
||||||
tools: {
|
{
|
||||||
exec: {
|
name: "warns for risky global and relative trusted dirs",
|
||||||
safeBinTrustedDirs: riskyGlobalTrustedDirs,
|
cfg: {
|
||||||
},
|
tools: {
|
||||||
},
|
exec: {
|
||||||
agents: {
|
safeBinTrustedDirs: riskyGlobalTrustedDirs,
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "ops",
|
|
||||||
tools: {
|
|
||||||
exec: {
|
|
||||||
safeBinTrustedDirs: ["./relative-bin-dir"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
agents: {
|
||||||
},
|
list: [
|
||||||
};
|
{
|
||||||
|
id: "ops",
|
||||||
const res = await audit(cfg);
|
tools: {
|
||||||
const finding = res.findings.find(
|
exec: {
|
||||||
(f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky",
|
safeBinTrustedDirs: ["./relative-bin-dir"],
|
||||||
);
|
},
|
||||||
expect(finding?.severity).toBe("warn");
|
},
|
||||||
expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]);
|
},
|
||||||
expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]);
|
],
|
||||||
expect(finding?.detail).toContain("agents.list.ops.tools.exec");
|
},
|
||||||
});
|
} satisfies OpenClawConfig,
|
||||||
|
assert: (res: SecurityAuditReport) => {
|
||||||
it("does not warn for non-risky absolute safeBinTrustedDirs entries", async () => {
|
const finding = res.findings.find(
|
||||||
const cfg: OpenClawConfig = {
|
(f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky",
|
||||||
tools: {
|
);
|
||||||
exec: {
|
expect(finding?.severity).toBe("warn");
|
||||||
safeBinTrustedDirs: ["/usr/libexec"],
|
expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]);
|
||||||
|
expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]);
|
||||||
|
expect(finding?.detail).toContain("agents.list.ops.tools.exec");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
name: "ignores non-risky absolute dirs",
|
||||||
|
cfg: {
|
||||||
|
tools: {
|
||||||
|
exec: {
|
||||||
|
safeBinTrustedDirs: ["/usr/libexec"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies OpenClawConfig,
|
||||||
|
assert: (res: SecurityAuditReport) => {
|
||||||
|
expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
const res = await audit(cfg);
|
await Promise.all(
|
||||||
expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky");
|
cases.map(async (testCase) => {
|
||||||
|
const res = await audit(testCase.cfg);
|
||||||
|
testCase.assert(res);
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("evaluates loopback control UI and logging exposure findings", async () => {
|
it("evaluates loopback control UI and logging exposure findings", async () => {
|
||||||
@@ -971,69 +994,80 @@ description: test skill
|
|||||||
expect(res.findings.some((f) => f.checkId === "fs.config.perms_group_readable")).toBe(false);
|
expect(res.findings.some((f) => f.checkId === "fs.config.perms_group_readable")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns when workspace skill files resolve outside workspace root", async () => {
|
it("evaluates workspace skill path escape findings", async () => {
|
||||||
if (isWindows) {
|
const cases = [
|
||||||
return;
|
{
|
||||||
|
name: "warns when workspace skill files resolve outside workspace root",
|
||||||
|
supported: !isWindows,
|
||||||
|
setup: async () => {
|
||||||
|
const tmp = await makeTmpDir("workspace-skill-symlink-escape");
|
||||||
|
const stateDir = path.join(tmp, "state");
|
||||||
|
const workspaceDir = path.join(tmp, "workspace");
|
||||||
|
const outsideDir = path.join(tmp, "outside");
|
||||||
|
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
||||||
|
await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true });
|
||||||
|
await fs.mkdir(outsideDir, { recursive: true });
|
||||||
|
|
||||||
|
const outsideSkillPath = path.join(outsideDir, "SKILL.md");
|
||||||
|
await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8");
|
||||||
|
await fs.symlink(outsideSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md"));
|
||||||
|
|
||||||
|
return { stateDir, workspaceDir, outsideSkillPath };
|
||||||
|
},
|
||||||
|
assert: (
|
||||||
|
res: SecurityAuditReport,
|
||||||
|
fixture: { stateDir: string; workspaceDir: string; outsideSkillPath: string },
|
||||||
|
) => {
|
||||||
|
const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape");
|
||||||
|
expect(finding?.severity).toBe("warn");
|
||||||
|
expect(finding?.detail).toContain(fixture.outsideSkillPath);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not warn for workspace skills that stay inside workspace root",
|
||||||
|
supported: true,
|
||||||
|
setup: async () => {
|
||||||
|
const tmp = await makeTmpDir("workspace-skill-in-root");
|
||||||
|
const stateDir = path.join(tmp, "state");
|
||||||
|
const workspaceDir = path.join(tmp, "workspace");
|
||||||
|
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
||||||
|
await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspaceDir, "skills", "safe", "SKILL.md"),
|
||||||
|
"# in workspace\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return { stateDir, workspaceDir };
|
||||||
|
},
|
||||||
|
assert: (res: SecurityAuditReport) => {
|
||||||
|
expectNoFinding(res, "skills.workspace.symlink_escape");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
if (!testCase.supported) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixture = await testCase.setup();
|
||||||
|
const configPath = path.join(fixture.stateDir, "openclaw.json");
|
||||||
|
await fs.writeFile(configPath, "{}\n", "utf-8");
|
||||||
|
if (!isWindows) {
|
||||||
|
await fs.chmod(configPath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: { agents: { defaults: { workspace: fixture.workspaceDir } } },
|
||||||
|
includeFilesystem: true,
|
||||||
|
includeChannelSecurity: false,
|
||||||
|
stateDir: fixture.stateDir,
|
||||||
|
configPath,
|
||||||
|
execDockerRawFn: execDockerRawUnavailable,
|
||||||
|
});
|
||||||
|
|
||||||
|
testCase.assert(res, fixture);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmp = await makeTmpDir("workspace-skill-symlink-escape");
|
|
||||||
const stateDir = path.join(tmp, "state");
|
|
||||||
const workspaceDir = path.join(tmp, "workspace");
|
|
||||||
const outsideDir = path.join(tmp, "outside");
|
|
||||||
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
||||||
await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true });
|
|
||||||
await fs.mkdir(outsideDir, { recursive: true });
|
|
||||||
|
|
||||||
const outsideSkillPath = path.join(outsideDir, "SKILL.md");
|
|
||||||
await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8");
|
|
||||||
await fs.symlink(outsideSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md"));
|
|
||||||
|
|
||||||
const configPath = path.join(stateDir, "openclaw.json");
|
|
||||||
await fs.writeFile(configPath, "{}\n", "utf-8");
|
|
||||||
await fs.chmod(configPath, 0o600);
|
|
||||||
|
|
||||||
const res = await runSecurityAudit({
|
|
||||||
config: { agents: { defaults: { workspace: workspaceDir } } },
|
|
||||||
includeFilesystem: true,
|
|
||||||
includeChannelSecurity: false,
|
|
||||||
stateDir,
|
|
||||||
configPath,
|
|
||||||
execDockerRawFn: execDockerRawUnavailable,
|
|
||||||
});
|
|
||||||
|
|
||||||
const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape");
|
|
||||||
expect(finding?.severity).toBe("warn");
|
|
||||||
expect(finding?.detail).toContain(outsideSkillPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for workspace skills that stay inside workspace root", async () => {
|
|
||||||
const tmp = await makeTmpDir("workspace-skill-in-root");
|
|
||||||
const stateDir = path.join(tmp, "state");
|
|
||||||
const workspaceDir = path.join(tmp, "workspace");
|
|
||||||
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
||||||
await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(workspaceDir, "skills", "safe", "SKILL.md"),
|
|
||||||
"# in workspace\n",
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const configPath = path.join(stateDir, "openclaw.json");
|
|
||||||
await fs.writeFile(configPath, "{}\n", "utf-8");
|
|
||||||
if (!isWindows) {
|
|
||||||
await fs.chmod(configPath, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await runSecurityAudit({
|
|
||||||
config: { agents: { defaults: { workspace: workspaceDir } } },
|
|
||||||
includeFilesystem: true,
|
|
||||||
includeChannelSecurity: false,
|
|
||||||
stateDir,
|
|
||||||
configPath,
|
|
||||||
execDockerRawFn: execDockerRawUnavailable,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.findings.some((f) => f.checkId === "skills.workspace.symlink_escape")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scores small-model risk by tool/sandbox exposure", async () => {
|
it("scores small-model risk by tool/sandbox exposure", async () => {
|
||||||
@@ -3210,131 +3244,89 @@ description: test skill
|
|||||||
expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true);
|
expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags enabled extensions when tool policy can expose plugin tools", async () => {
|
it("evaluates extension tool reachability findings", async () => {
|
||||||
const stateDir = sharedExtensionsStateDir;
|
const cases = [
|
||||||
|
{
|
||||||
const cfg: OpenClawConfig = {
|
name: "flags enabled extensions when tool policy can expose plugin tools",
|
||||||
plugins: { allow: ["some-plugin"] },
|
cfg: {
|
||||||
};
|
plugins: { allow: ["some-plugin"] },
|
||||||
const res = await runSecurityAudit({
|
} satisfies OpenClawConfig,
|
||||||
config: cfg,
|
assert: (res: SecurityAuditReport) => {
|
||||||
includeFilesystem: true,
|
expect(res.findings).toEqual(
|
||||||
includeChannelSecurity: false,
|
expect.arrayContaining([
|
||||||
stateDir,
|
expect.objectContaining({
|
||||||
configPath: path.join(stateDir, "openclaw.json"),
|
checkId: "plugins.tools_reachable_permissive_policy",
|
||||||
execDockerRawFn: execDockerRawUnavailable,
|
severity: "warn",
|
||||||
});
|
}),
|
||||||
|
]),
|
||||||
expect(res.findings).toEqual(
|
);
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
checkId: "plugins.tools_reachable_permissive_policy",
|
|
||||||
severity: "warn",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not flag plugin tool reachability when profile is restrictive", async () => {
|
|
||||||
const stateDir = sharedExtensionsStateDir;
|
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
plugins: { allow: ["some-plugin"] },
|
|
||||||
tools: { profile: "coding" },
|
|
||||||
};
|
|
||||||
const res = await runSecurityAudit({
|
|
||||||
config: cfg,
|
|
||||||
includeFilesystem: true,
|
|
||||||
includeChannelSecurity: false,
|
|
||||||
stateDir,
|
|
||||||
configPath: path.join(stateDir, "openclaw.json"),
|
|
||||||
execDockerRawFn: execDockerRawUnavailable,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => {
|
|
||||||
const prevDiscordToken = process.env.DISCORD_BOT_TOKEN;
|
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const stateDir = sharedExtensionsStateDir;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
channels: {
|
|
||||||
discord: { enabled: true, token: "t" },
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
const res = await runSecurityAudit({
|
{
|
||||||
config: cfg,
|
name: "does not flag plugin tool reachability when profile is restrictive",
|
||||||
includeFilesystem: true,
|
cfg: {
|
||||||
includeChannelSecurity: false,
|
plugins: { allow: ["some-plugin"] },
|
||||||
stateDir,
|
tools: { profile: "coding" },
|
||||||
configPath: path.join(stateDir, "openclaw.json"),
|
} satisfies OpenClawConfig,
|
||||||
execDockerRawFn: execDockerRawUnavailable,
|
assert: (res: SecurityAuditReport) => {
|
||||||
});
|
expect(
|
||||||
|
res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"),
|
||||||
expect(res.findings).toEqual(
|
).toBe(false);
|
||||||
expect.arrayContaining([
|
},
|
||||||
expect.objectContaining({
|
},
|
||||||
checkId: "plugins.extensions_no_allowlist",
|
{
|
||||||
severity: "critical",
|
name: "flags unallowlisted extensions as critical when native skill commands are exposed",
|
||||||
}),
|
cfg: {
|
||||||
]),
|
channels: {
|
||||||
);
|
discord: { enabled: true, token: "t" },
|
||||||
} finally {
|
|
||||||
if (prevDiscordToken == null) {
|
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.DISCORD_BOT_TOKEN = prevDiscordToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats SecretRef channel credentials as configured for extension allowlist severity", async () => {
|
|
||||||
const prevDiscordToken = process.env.DISCORD_BOT_TOKEN;
|
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const stateDir = sharedExtensionsStateDir;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
channels: {
|
|
||||||
discord: {
|
|
||||||
enabled: true,
|
|
||||||
token: {
|
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "DISCORD_BOT_TOKEN",
|
|
||||||
} as unknown as string,
|
|
||||||
},
|
},
|
||||||
|
} satisfies OpenClawConfig,
|
||||||
|
assert: (res: SecurityAuditReport) => {
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "plugins.extensions_no_allowlist",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
const res = await runSecurityAudit({
|
{
|
||||||
config: cfg,
|
name: "treats SecretRef channel credentials as configured for extension allowlist severity",
|
||||||
includeFilesystem: true,
|
cfg: {
|
||||||
includeChannelSecurity: false,
|
channels: {
|
||||||
stateDir,
|
discord: {
|
||||||
configPath: path.join(stateDir, "openclaw.json"),
|
enabled: true,
|
||||||
execDockerRawFn: execDockerRawUnavailable,
|
token: {
|
||||||
});
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "DISCORD_BOT_TOKEN",
|
||||||
|
} as unknown as string,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies OpenClawConfig,
|
||||||
|
assert: (res: SecurityAuditReport) => {
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "plugins.extensions_no_allowlist",
|
||||||
|
severity: "critical",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
expect(res.findings).toEqual(
|
await withEnvAsync({ DISCORD_BOT_TOKEN: undefined }, async () => {
|
||||||
expect.arrayContaining([
|
await Promise.all(
|
||||||
expect.objectContaining({
|
cases.map(async (testCase) => {
|
||||||
checkId: "plugins.extensions_no_allowlist",
|
const res = await runSharedExtensionsAudit(testCase.cfg);
|
||||||
severity: "critical",
|
testCase.assert(res);
|
||||||
}),
|
}),
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
} finally {
|
});
|
||||||
if (prevDiscordToken == null) {
|
|
||||||
delete process.env.DISCORD_BOT_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.DISCORD_BOT_TOKEN = prevDiscordToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not scan plugin code safety findings when deep audit is disabled", async () => {
|
it("does not scan plugin code safety findings when deep audit is disabled", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user