mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 07:10:26 +00:00
chore: merge origin/main into main
This commit is contained in:
@@ -296,6 +296,70 @@ describe("security audit", () => {
|
||||
expect(hasFinding(res, "tools.exec.host_sandbox_no_sandbox_agents", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns for interpreter safeBins entries without explicit profiles", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
safeBins: ["python3"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
safeBins: ["node"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const res = await audit(cfg);
|
||||
|
||||
expect(hasFinding(res, "tools.exec.safe_bins_interpreter_unprofiled", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not warn for interpreter safeBins when explicit profiles are present", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
safeBins: ["python3"],
|
||||
safeBinProfiles: {
|
||||
python3: {
|
||||
maxPositional: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
safeBins: ["node"],
|
||||
safeBinProfiles: {
|
||||
node: {
|
||||
maxPositional: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const res = await audit(cfg);
|
||||
|
||||
expect(
|
||||
res.findings.some((f) => f.checkId === "tools.exec.safe_bins_interpreter_unprofiled"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("warns when loopback control UI lacks trusted proxies", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
@@ -974,6 +1038,20 @@ describe("security audit", () => {
|
||||
});
|
||||
|
||||
it("scores X-Real-IP fallback risk by gateway exposure", async () => {
|
||||
const trustedProxyCfg = (trustedProxies: string[]): OpenClawConfig => ({
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
allowRealIpFallback: true,
|
||||
trustedProxies,
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -1011,36 +1089,22 @@ describe("security audit", () => {
|
||||
},
|
||||
{
|
||||
name: "loopback trusted-proxy with loopback-only proxies",
|
||||
cfg: {
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
allowRealIpFallback: true,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: trustedProxyCfg(["127.0.0.1"]),
|
||||
expectedSeverity: "warn",
|
||||
},
|
||||
{
|
||||
name: "loopback trusted-proxy with non-loopback proxy range",
|
||||
cfg: {
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
allowRealIpFallback: true,
|
||||
trustedProxies: ["127.0.0.1", "10.0.0.0/8"],
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg: trustedProxyCfg(["127.0.0.1", "10.0.0.0/8"]),
|
||||
expectedSeverity: "critical",
|
||||
},
|
||||
{
|
||||
name: "loopback trusted-proxy with 127.0.0.2",
|
||||
cfg: trustedProxyCfg(["127.0.0.2"]),
|
||||
expectedSeverity: "critical",
|
||||
},
|
||||
{
|
||||
name: "loopback trusted-proxy with 127.0.0.0/8 range",
|
||||
cfg: trustedProxyCfg(["127.0.0.0/8"]),
|
||||
expectedSeverity: "critical",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,9 +9,12 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { isLoopbackAddress } from "../gateway/net.js";
|
||||
import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
import {
|
||||
listInterpreterLikeSafeBins,
|
||||
resolveMergedSafeBinProfileFixtures,
|
||||
} from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { collectChannelSecurityFindings } from "./audit-channel.js";
|
||||
import {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
@@ -340,7 +343,7 @@ function collectGatewayConfigFindings(
|
||||
|
||||
if (allowRealIpFallback) {
|
||||
const hasNonLoopbackTrustedProxy = trustedProxies.some(
|
||||
(proxy) => !isLoopbackOnlyTrustedProxyEntry(proxy),
|
||||
(proxy) => !isStrictLoopbackTrustedProxyEntry(proxy),
|
||||
);
|
||||
const exposed =
|
||||
bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy);
|
||||
@@ -508,13 +511,15 @@ function collectGatewayConfigFindings(
|
||||
return findings;
|
||||
}
|
||||
|
||||
function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean {
|
||||
// Keep this stricter than isLoopbackAddress on purpose: this check is for
|
||||
// trust boundaries, so only explicit localhost proxy hops are treated as local.
|
||||
function isStrictLoopbackTrustedProxyEntry(entry: string): boolean {
|
||||
const candidate = entry.trim();
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (!candidate.includes("/")) {
|
||||
return isLoopbackAddress(candidate);
|
||||
return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1";
|
||||
}
|
||||
|
||||
const [rawIp, rawPrefix] = candidate.split("/", 2);
|
||||
@@ -527,11 +532,7 @@ function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean {
|
||||
return false;
|
||||
}
|
||||
if (ipVersion === 4) {
|
||||
if (prefix < 8 || prefix > 32) {
|
||||
return false;
|
||||
}
|
||||
const firstOctet = Number.parseInt(rawIp.trim().split(".")[0] ?? "", 10);
|
||||
return firstOctet === 127;
|
||||
return rawIp.trim() === "127.0.0.1" && prefix === 32;
|
||||
}
|
||||
if (ipVersion === 6) {
|
||||
return prefix === 128 && rawIp.trim().toLowerCase() === "::1";
|
||||
@@ -698,6 +699,65 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[]
|
||||
});
|
||||
}
|
||||
|
||||
const normalizeConfiguredSafeBins = (entries: unknown): string[] => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
new Set(
|
||||
entries
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
|
||||
.filter((entry) => entry.length > 0),
|
||||
),
|
||||
).toSorted();
|
||||
};
|
||||
const interpreterHits: string[] = [];
|
||||
const globalExec = cfg.tools?.exec;
|
||||
const globalSafeBins = normalizeConfiguredSafeBins(globalExec?.safeBins);
|
||||
if (globalSafeBins.length > 0) {
|
||||
const merged = resolveMergedSafeBinProfileFixtures({ global: globalExec }) ?? {};
|
||||
const interpreters = listInterpreterLikeSafeBins(globalSafeBins).filter((bin) => !merged[bin]);
|
||||
if (interpreters.length > 0) {
|
||||
interpreterHits.push(`- tools.exec.safeBins: ${interpreters.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of agents) {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const agentExec = entry.tools?.exec;
|
||||
const agentSafeBins = normalizeConfiguredSafeBins(agentExec?.safeBins);
|
||||
if (agentSafeBins.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const merged =
|
||||
resolveMergedSafeBinProfileFixtures({
|
||||
global: globalExec,
|
||||
local: agentExec,
|
||||
}) ?? {};
|
||||
const interpreters = listInterpreterLikeSafeBins(agentSafeBins).filter((bin) => !merged[bin]);
|
||||
if (interpreters.length === 0) {
|
||||
continue;
|
||||
}
|
||||
interpreterHits.push(
|
||||
`- agents.list.${entry.id}.tools.exec.safeBins: ${interpreters.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (interpreterHits.length > 0) {
|
||||
findings.push({
|
||||
checkId: "tools.exec.safe_bins_interpreter_unprofiled",
|
||||
severity: "warn",
|
||||
title: "safeBins includes interpreter/runtime binaries without explicit profiles",
|
||||
detail:
|
||||
`Detected interpreter-like safeBins entries missing explicit profiles:\n${interpreterHits.join("\n")}\n` +
|
||||
"These entries can turn safeBins into a broad execution surface when used with permissive argv profiles.",
|
||||
remediation:
|
||||
"Remove interpreter/runtime bins from safeBins (prefer allowlist entries) or define hardened tools.exec.safeBinProfiles.<bin> rules.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listRuntimeSourceFiles } from "../test-utils/repo-scan.js";
|
||||
|
||||
const SCAN_ROOTS = ["src", "extensions"] as const;
|
||||
const SKIP_DIRS = new Set([".git", "dist", "node_modules"]);
|
||||
|
||||
function collectTypeScriptFiles(rootDir: string): string[] {
|
||||
const out: string[] = [];
|
||||
const stack = [rootDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) {
|
||||
stack.push(fullPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!entry.name.endsWith(".ts") ||
|
||||
entry.name.endsWith(".test.ts") ||
|
||||
entry.name.endsWith(".d.ts")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out.push(fullPath);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findWeakRandomPatternMatches(repoRoot: string): string[] {
|
||||
async function findWeakRandomPatternMatches(repoRoot: string): Promise<string[]> {
|
||||
const matches: string[] = [];
|
||||
for (const scanRoot of SCAN_ROOTS) {
|
||||
const root = path.join(repoRoot, scanRoot);
|
||||
if (!fs.existsSync(root)) {
|
||||
continue;
|
||||
}
|
||||
const files = collectTypeScriptFiles(root);
|
||||
for (const filePath of files) {
|
||||
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
for (let idx = 0; idx < lines.length; idx += 1) {
|
||||
const line = lines[idx] ?? "";
|
||||
if (!line.includes("Date.now") || !line.includes("Math.random")) {
|
||||
continue;
|
||||
}
|
||||
matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`);
|
||||
const files = await listRuntimeSourceFiles(repoRoot, {
|
||||
roots: SCAN_ROOTS,
|
||||
extensions: [".ts"],
|
||||
});
|
||||
for (const filePath of files) {
|
||||
const lines = (await fs.readFile(filePath, "utf8")).split(/\r?\n/);
|
||||
for (let idx = 0; idx < lines.length; idx += 1) {
|
||||
const line = lines[idx] ?? "";
|
||||
if (!line.includes("Date.now") || !line.includes("Math.random")) {
|
||||
continue;
|
||||
}
|
||||
matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
describe("weak random pattern guardrail", () => {
|
||||
it("rejects Date.now + Math.random token/id patterns in runtime code", () => {
|
||||
it("rejects Date.now + Math.random token/id patterns in runtime code", async () => {
|
||||
const repoRoot = path.resolve(process.cwd());
|
||||
const matches = findWeakRandomPatternMatches(repoRoot);
|
||||
const matches = await findWeakRandomPatternMatches(repoRoot);
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user