Files
clawsec/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs
T
Aldo Delgado 7cdb4ab7e2 fix(portability): harden cross-platform path handling and install workflows (#62)
* docs: add agent collaboration and git safety rules to AGENTS.md

* fix(portability): harden cross-platform path handling and install workflows

- add shared path resolution utility for advisory guardian components
- expand and normalize home-path tokens: ~, $HOME, ${HOME}, %USERPROFILE%, $env:USERPROFILE
- reject unresolved/escaped home tokens to prevent literal "$HOME" directory creation
- fix install/runtime path handling in:
  - openclaw-audit-watchdog setup_cron and suppression config loader
  - clawsec-suite advisory hook handler, suppression loader, and guarded installer
- remove hardcoded Homebrew binary assumptions in watchdog scripts/tests
- add LF enforcement via .gitattributes to reduce CRLF script breakage
- expand CI Node checks to linux/macos/windows matrix
- add cross-platform test coverage for path expansion and token rejection
- update README and SKILL docs with bash/zsh/PowerShell-safe path guidance
- add compatibility deliverables:
  - docs/COMPATIBILITY_REPORT.md
  - docs/REMEDIATION_PLAN.md
  - docs/PLATFORM_VERIFICATION.md

Validation:
- node skills/clawsec-suite/test/path_resolution.test.mjs
- node skills/clawsec-suite/test/guarded_install.test.mjs
- node skills/clawsec-suite/test/advisory_suppression.test.mjs
- node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
- node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs

* fix(advisory): avoid fail-open on invalid path vars and cover watchdog tests

* docs: move signing runbooks into docs folder

* docs: remove root-level signing runbooks after move

* chore(clawsec-suite): bump version to 0.1.3

* chore(openclaw-audit-watchdog): bump version to 0.1.1

* docs(changelog): add entries for clawsec-suite 0.1.3 and watchdog 0.1.1

* docs(changelog): credit @aldodelgado for PR #62 contributions

* feat(clawsec-suite): scope advisories to openclaw application

* fix(ci): run advisory scope tests without TypeScript loader

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-02-25 13:24:31 +02:00

270 lines
8.1 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Setup: create/update a daily 23:00 cron job that
* - runs openclaw security audits
* - DMs a chosen recipient (channel+id)
* - emails target@example.com via local sendmail
*
* Uses the `openclaw cron` CLI so it can run on a host without direct Gateway RPC access.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
const JOB_NAME = "Daily security audit (Prompt Security)";
const COMPANY_EMAIL = "target@example.com";
const DEFAULT_TZ = "UTC";
const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily
const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const UNEXPANDED_HOME_TOKEN_PATTERN =
/(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i;
function sh(cmd, args, { input } = {}) {
const res = spawnSync(cmd, args, {
encoding: "utf8",
input: input ?? undefined,
stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
});
if (res.error) throw res.error;
if (res.status !== 0) {
const msg = (res.stderr || res.stdout || "").trim();
throw new Error(`${cmd} ${args.join(" ")} failed (code ${res.status})${msg ? `: ${msg}` : ""}`);
}
return res.stdout;
}
async function prompt(question, { defaultValue = "" } = {}) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const q = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
const answer = await new Promise((resolve) => rl.question(q, resolve));
rl.close();
const trimmed = String(answer ?? "").trim();
return trimmed || defaultValue;
}
function envOrEmpty(name) {
const v = process.env[name];
return typeof v === "string" ? v.trim() : "";
}
function detectHomeDirectory() {
const home = envOrEmpty("HOME");
if (home) return home;
const userProfile = envOrEmpty("USERPROFILE");
if (userProfile) return userProfile;
const homeDrive = envOrEmpty("HOMEDRIVE");
const homePath = envOrEmpty("HOMEPATH");
if (homeDrive && homePath) return `${homeDrive}${homePath}`;
return os.homedir();
}
function resolveUserPath(inputPath, label) {
const raw = String(inputPath ?? "").trim();
if (!raw) return raw;
const homeDir = detectHomeDirectory();
let expanded = raw;
if (expanded === "~") {
expanded = homeDir;
} else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
expanded = path.join(homeDir, expanded.slice(2));
}
expanded = expanded
.replace(/(?<!\\)\$\{HOME\}/g, homeDir)
.replace(/(?<!\\)\$HOME(?=$|[\\/])/g, homeDir)
.replace(/(?<!\\)\$\{USERPROFILE\}/gi, homeDir)
.replace(/(?<!\\)\$USERPROFILE(?=$|[\\/])/gi, homeDir)
.replace(/%HOME%/gi, homeDir)
.replace(/%USERPROFILE%/gi, homeDir)
.replace(/(?<!\\)\$env:HOME/gi, homeDir)
.replace(/(?<!\\)\$env:USERPROFILE/gi, homeDir);
const normalized = path.normalize(expanded);
if (UNEXPANDED_HOME_TOKEN_PATTERN.test(normalized)) {
throw new Error(
`Unexpanded home token detected in ${label}: ${raw}. ` +
"Use an absolute path or an unquoted home-path expression.",
);
}
return normalized;
}
function oneline(v) {
return String(v ?? "")
.replace(/[\r\n]+/g, " ")
.replace(/\\/g, "\\\\")
.replace(/"/g, "\\\"")
.trim();
}
function escapeForShellEnvVar(v) {
return String(v ?? "")
.replace(/[\r\n]+/g, " ")
.replace(/\\/g, "\\\\")
.replace(/\$/g, "\\$")
.replace(/`/g, "\\`")
.replace(/"/g, "\\\"")
.trim();
}
function defaultInstallDir() {
const env = envOrEmpty("PROMPTSEC_INSTALL_DIR");
if (env) return resolveUserPath(env, "PROMPTSEC_INSTALL_DIR");
const home = detectHomeDirectory();
if (home) return path.join(home, ".config", "security-checkup");
return resolveUserPath(SCRIPT_ROOT, "script root");
}
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
const safeDir = escapeForShellEnvVar(installDir || "");
const escapedHostLabel = escapeForShellEnvVar(hostLabel);
return [
"Run daily openclaw security audits and deliver report (DM + email).",
"",
`Delivery DM: ${oneline(dmChannel)}:${oneline(dmTo)}`,
`Email: ${COMPANY_EMAIL} (local sendmail)`,
"",
"Execute:",
`- Run via exec: cd "${safeDir}" && PROMPTSEC_HOST_LABEL="${escapedHostLabel}" ./scripts/runner.sh`,
"",
"Output requirements:",
"- Print the report to stdout (cron deliver will DM it).",
`- Also email the same report to ${COMPANY_EMAIL}; if email fails, append a NOTE line to stdout.`,
"- Do not apply fixes automatically.",
].join("\n");
}
function findExistingJobId(listJson) {
const jobs = Array.isArray(listJson?.jobs) ? listJson.jobs : [];
const match = jobs.find((j) => j?.name === JOB_NAME);
return match?.id ?? null;
}
async function run() {
// Non-interactive first (MDM-friendly)
const tzEnv = envOrEmpty("PROMPTSEC_TZ");
const dmChannelEnv = envOrEmpty("PROMPTSEC_DM_CHANNEL");
const dmToEnv = envOrEmpty("PROMPTSEC_DM_TO");
const hostLabelEnv = envOrEmpty("PROMPTSEC_HOST_LABEL");
const interactive = !(tzEnv && dmChannelEnv && dmToEnv);
const tz = interactive
? await prompt("Timezone for daily 11pm run (IANA)", { defaultValue: tzEnv || DEFAULT_TZ })
: tzEnv || DEFAULT_TZ;
const dmChannel = interactive
? await prompt("DM channel (e.g. telegram, slack, discord)", { defaultValue: dmChannelEnv })
: dmChannelEnv;
const dmTo = interactive
? await prompt("DM recipient id (Telegram numeric chatId/userId preferred)", { defaultValue: dmToEnv })
: dmToEnv;
const hostLabel = interactive
? await prompt("Optional host label to include in report", { defaultValue: hostLabelEnv })
: hostLabelEnv;
const installDirDefault = defaultInstallDir();
const installDirInput = interactive
? await prompt("Install dir containing scripts/runner.sh", { defaultValue: installDirDefault })
: installDirDefault;
const installDir = resolveUserPath(installDirInput, "install dir containing scripts/runner.sh");
if (!dmChannel || !dmTo) {
throw new Error("Missing DM target. Set PROMPTSEC_DM_CHANNEL and PROMPTSEC_DM_TO (or run interactively). ");
}
const runnerPath = path.join(installDir, "scripts", "runner.sh");
if (!fs.existsSync(runnerPath)) {
throw new Error(`runner.sh not found at ${runnerPath}; set PROMPTSEC_INSTALL_DIR to the deployed path`);
}
const listOut = sh("openclaw", ["cron", "list", "--json"]);
const listJson = JSON.parse(listOut);
const existingId = findExistingJobId(listJson);
const agentMessage = buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir });
const description = `Runs openclaw security audit daily and delivers to ${dmChannel}:${dmTo} + ${COMPANY_EMAIL}.`;
if (!existingId) {
const args = [
"cron",
"add",
"--name",
JOB_NAME,
"--description",
description,
"--session",
"isolated",
"--wake",
"now",
"--cron",
DEFAULT_EXPR,
"--tz",
tz,
"--message",
agentMessage,
"--deliver",
"--channel",
dmChannel,
"--to",
dmTo,
"--best-effort-deliver",
"--post-prefix",
"[daily security audit]",
"--post-mode",
"summary",
"--json",
];
const out = sh("openclaw", args);
const job = JSON.parse(out);
process.stdout.write(`Created cron job ${job.id}: ${JOB_NAME}\n`);
} else {
const args = [
"cron",
"edit",
existingId,
"--name",
JOB_NAME,
"--description",
description,
"--enable",
"--session",
"isolated",
"--wake",
"now",
"--cron",
DEFAULT_EXPR,
"--tz",
tz,
"--message",
agentMessage,
"--deliver",
"--channel",
dmChannel,
"--to",
dmTo,
"--best-effort-deliver",
"--post-prefix",
"[daily security audit]",
];
sh("openclaw", args);
process.stdout.write(`Updated cron job ${existingId}: ${JOB_NAME}\n`);
}
}
run().catch((err) => {
process.stderr.write(String(err?.stack || err) + "\n");
process.exit(1);
});