mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 14:00:51 +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 stateDir = await makeTmpDir("audit-scanner-shared");
|
||||
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 =
|
||||
process.platform === "win32"
|
||||
? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`]
|
||||
: ["/usr/local/bin", "/tmp/openclaw-safe-bins"];
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinTrustedDirs: riskyGlobalTrustedDirs,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinTrustedDirs: ["./relative-bin-dir"],
|
||||
},
|
||||
const cases = [
|
||||
{
|
||||
name: "warns for risky global and relative trusted dirs",
|
||||
cfg: {
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinTrustedDirs: riskyGlobalTrustedDirs,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const res = await audit(cfg);
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky",
|
||||
);
|
||||
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");
|
||||
});
|
||||
|
||||
it("does not warn for non-risky absolute safeBinTrustedDirs entries", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinTrustedDirs: ["/usr/libexec"],
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
safeBinTrustedDirs: ["./relative-bin-dir"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
assert: (res: SecurityAuditReport) => {
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky",
|
||||
);
|
||||
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");
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
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);
|
||||
expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky");
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
testCase.assert(res);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("warns when workspace skill files resolve outside workspace root", async () => {
|
||||
if (isWindows) {
|
||||
return;
|
||||
it("evaluates workspace skill path escape findings", async () => {
|
||||
const cases = [
|
||||
{
|
||||
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 () => {
|
||||
@@ -3210,131 +3244,89 @@ description: test skill
|
||||
expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags enabled extensions when tool policy can expose plugin tools", async () => {
|
||||
const stateDir = sharedExtensionsStateDir;
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: { allow: ["some-plugin"] },
|
||||
};
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
execDockerRawFn: execDockerRawUnavailable,
|
||||
});
|
||||
|
||||
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" },
|
||||
it("evaluates extension tool reachability findings", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "flags enabled extensions when tool policy can expose plugin tools",
|
||||
cfg: {
|
||||
plugins: { allow: ["some-plugin"] },
|
||||
} satisfies OpenClawConfig,
|
||||
assert: (res: SecurityAuditReport) => {
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "plugins.tools_reachable_permissive_policy",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
};
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
execDockerRawFn: execDockerRawUnavailable,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "plugins.extensions_no_allowlist",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} 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,
|
||||
},
|
||||
{
|
||||
name: "does not flag plugin tool reachability when profile is restrictive",
|
||||
cfg: {
|
||||
plugins: { allow: ["some-plugin"] },
|
||||
tools: { profile: "coding" },
|
||||
} satisfies OpenClawConfig,
|
||||
assert: (res: SecurityAuditReport) => {
|
||||
expect(
|
||||
res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"),
|
||||
).toBe(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flags unallowlisted extensions as critical when native skill commands are exposed",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: { enabled: true, token: "t" },
|
||||
},
|
||||
} 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,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
execDockerRawFn: execDockerRawUnavailable,
|
||||
});
|
||||
},
|
||||
{
|
||||
name: "treats SecretRef channel credentials as configured for extension allowlist severity",
|
||||
cfg: {
|
||||
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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "plugins.extensions_no_allowlist",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
await withEnvAsync({ DISCORD_BOT_TOKEN: undefined }, async () => {
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await runSharedExtensionsAudit(testCase.cfg);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user