chore: merge origin/main into main

This commit is contained in:
Peter Steinberger
2026-02-22 13:42:52 +00:00
304 changed files with 17041 additions and 5502 deletions

View File

@@ -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",
},
];

View File

@@ -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;
}

View File

@@ -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([]);
});
});