diff --git a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs index 0e354a54edc9..c00b56d4a619 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs +++ b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs @@ -9,6 +9,7 @@ const scratchRoot = process.env.KITCHEN_SINK_TMP_DIR || os.tmpdir(); const LOG_SCAN_CHUNK_BYTES = 64 * 1024; const LOG_SCAN_FINDING_CONTEXT_CHARS = 2048; +const LOG_SCAN_MAX_ENTRIES = readPositiveIntEnv("KITCHEN_SINK_LOG_SCAN_MAX_ENTRIES", 20_000); const LOG_SCAN_MAX_FILES = 5000; const LOG_SCAN_MAX_FINDINGS = 100; const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024; @@ -18,6 +19,22 @@ const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); const scratchFile = (name) => path.join(scratchRoot, name); const normalizedPath = (filePath) => filePath.replaceAll("\\", "/"); +function readPositiveIntEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") { + return fallback; + } + const text = raw.trim(); + if (!/^\d+$/u.test(text)) { + throw new Error(`${name} must be a positive integer; got: ${raw}`); + } + const parsed = Number(text); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer; got: ${raw}`); + } + return parsed; +} + function resolveHomePath(value) { if (value === "~") { return process.env.HOME; @@ -115,6 +132,7 @@ function shouldScanLogFile(entry) { function scanLogFiles(roots, onFile) { let scannedFiles = 0; + let visitedEntries = 0; for (const root of roots) { const pending = [root]; while (pending.length > 0) { @@ -122,6 +140,12 @@ function scanLogFiles(roots, onFile) { if (!entry || !fs.existsSync(entry)) { continue; } + visitedEntries += 1; + if (visitedEntries > LOG_SCAN_MAX_ENTRIES) { + throw new Error( + `kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`, + ); + } const stat = fs.lstatSync(entry); if (stat.isSymbolicLink()) { continue; diff --git a/test/scripts/kitchen-sink-plugin-assertions.test.ts b/test/scripts/kitchen-sink-plugin-assertions.test.ts index 56086677a27c..4a9dbee55c71 100644 --- a/test/scripts/kitchen-sink-plugin-assertions.test.ts +++ b/test/scripts/kitchen-sink-plugin-assertions.test.ts @@ -193,11 +193,20 @@ function runAssertClawhubInstalled({ } } -function runScanLogs({ home, scratchRoot }: { home: string; scratchRoot: string }) { +function runScanLogs({ + env = {}, + home, + scratchRoot, +}: { + env?: NodeJS.ProcessEnv; + home: string; + scratchRoot: string; +}) { return spawnSync(process.execPath, [ASSERTIONS_SCRIPT, "scan-logs"], { encoding: "utf8", env: { ...process.env, + ...env, HOME: home, KITCHEN_SINK_TMP_DIR: scratchRoot, }, @@ -291,6 +300,35 @@ describe("kitchen-sink plugin assertions", () => { } }); + it("bounds irrelevant OpenClaw home traversal during log scans", () => { + const parent = mkdtempSync(path.join(tmpdir(), "openclaw-kitchen-sink-scan-")); + const home = path.join(parent, "home"); + const scratchRoot = path.join(parent, "scratch"); + try { + mkdirSync(path.join(home, ".openclaw"), { recursive: true }); + mkdirSync(scratchRoot, { recursive: true }); + writeFileSync(path.join(scratchRoot, "scenario.log"), "0 errors\n"); + for (let index = 0; index < 20; index += 1) { + const dir = path.join(home, ".openclaw", `cache-${index}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "state.txt"), "not a log\n"); + } + + const result = runScanLogs({ + env: { KITCHEN_SINK_LOG_SCAN_MAX_ENTRIES: "8" }, + home, + scratchRoot, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "kitchen-sink log scan exceeded 8 filesystem entries", + ); + } finally { + rmSync(parent, { force: true, recursive: true }); + } + }); + it("does not allow dirty error lines just because they mention zero errors", () => { const parent = mkdtempSync(path.join(tmpdir(), "openclaw-kitchen-sink-scan-")); const home = path.join(parent, "home");