diff --git a/src/cron/run-log.test.ts b/src/cron/run-log.test.ts index 3cf1ee1cad2..b44603b0188 100644 --- a/src/cron/run-log.test.ts +++ b/src/cron/run-log.test.ts @@ -95,6 +95,22 @@ describe("cron run log", () => { }); }); + it("writes run log files with secure permissions", async () => { + await withRunLogDir("openclaw-cron-log-perms-", async (dir) => { + const logPath = path.join(dir, "runs", "job-1.jsonl"); + + await appendCronRunLog(logPath, { + ts: 1, + jobId: "job-1", + action: "finished", + status: "ok", + }); + + const mode = (await fs.stat(logPath)).mode & 0o777; + expect(mode).toBe(0o600); + }); + }); + it("reads newest entries and filters by jobId", async () => { await withRunLogDir("openclaw-cron-log-read-", async (dir) => { const logPathA = path.join(dir, "runs", "a.jsonl"); diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index ce82c693c25..097c76544c2 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -75,6 +75,10 @@ export function resolveCronRunLogPath(params: { storePath: string; jobId: string const writesByPath = new Map>(); +async function setSecureFileMode(filePath: string): Promise { + await fs.chmod(filePath, 0o600).catch(() => undefined); +} + export const DEFAULT_CRON_RUN_LOG_MAX_BYTES = 2_000_000; export const DEFAULT_CRON_RUN_LOG_KEEP_LINES = 2_000; @@ -125,8 +129,10 @@ async function pruneIfNeeded(filePath: string, opts: { maxBytes: number; keepLin const kept = lines.slice(Math.max(0, lines.length - opts.keepLines)); const { randomBytes } = await import("node:crypto"); const tmp = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; - await fs.writeFile(tmp, `${kept.join("\n")}\n`, "utf-8"); + await fs.writeFile(tmp, `${kept.join("\n")}\n`, { encoding: "utf-8", mode: 0o600 }); + await setSecureFileMode(tmp); await fs.rename(tmp, filePath); + await setSecureFileMode(filePath); } export async function appendCronRunLog( @@ -139,8 +145,12 @@ export async function appendCronRunLog( const next = prev .catch(() => undefined) .then(async () => { - await fs.mkdir(path.dirname(resolved), { recursive: true }); - await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, "utf-8"); + await fs.mkdir(path.dirname(resolved), { recursive: true, mode: 0o700 }); + await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + await setSecureFileMode(resolved); await pruneIfNeeded(resolved, { maxBytes: opts?.maxBytes ?? DEFAULT_CRON_RUN_LOG_MAX_BYTES, keepLines: opts?.keepLines ?? DEFAULT_CRON_RUN_LOG_KEEP_LINES, diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 1d318671437..ad388a5979b 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -79,6 +79,21 @@ describe("cron store", () => { expect(JSON.parse(currentRaw)).toEqual(second); expect(JSON.parse(backupRaw)).toEqual(first); }); + + it("writes store and backup files with secure permissions", async () => { + const store = await makeStorePath(); + const first = makeStore("job-1", true); + const second = makeStore("job-2", false); + + await saveCronStore(store.storePath, first); + await saveCronStore(store.storePath, second); + + const storeMode = (await fs.stat(store.storePath)).mode & 0o777; + const backupMode = (await fs.stat(`${store.storePath}.bak`)).mode & 0o777; + + expect(storeMode).toBe(0o600); + expect(backupMode).toBe(0o600); + }); }); describe("saveCronStore", () => { diff --git a/src/cron/store.ts b/src/cron/store.ts index 70fd978aab6..ca540aa0b36 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -56,12 +56,16 @@ type SaveCronStoreOptions = { skipBackup?: boolean; }; +async function setSecureFileMode(filePath: string): Promise { + await fs.promises.chmod(filePath, 0o600).catch(() => undefined); +} + export async function saveCronStore( storePath: string, store: CronStoreFile, opts?: SaveCronStoreOptions, ) { - await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); + await fs.promises.mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 }); const json = JSON.stringify(store, null, 2); const cached = serializedStoreCache.get(storePath); if (cached === json) { @@ -83,15 +87,19 @@ export async function saveCronStore( return; } const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; - await fs.promises.writeFile(tmp, json, "utf-8"); + await fs.promises.writeFile(tmp, json, { encoding: "utf-8", mode: 0o600 }); + await setSecureFileMode(tmp); if (previous !== null && !opts?.skipBackup) { try { - await fs.promises.copyFile(storePath, `${storePath}.bak`); + const backupPath = `${storePath}.bak`; + await fs.promises.copyFile(storePath, backupPath); + await setSecureFileMode(backupPath); } catch { // best-effort } } await renameWithRetry(tmp, storePath); + await setSecureFileMode(storePath); serializedStoreCache.set(storePath, json); }