mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-16 06:51:21 +03:00
e6a1765a7f
* fix(openclaw-audit-watchdog): avoid dangerous-exec gate false positives * fix(openclaw-audit-watchdog): align frontmatter runtime metadata * fix(openclaw-audit-watchdog): normalize release version to 0.1.3
356 lines
11 KiB
JavaScript
Executable File
356 lines
11 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)
|
|
* - optionally emails a configured recipient via sendmail/SMTP
|
|
*
|
|
* Uses the `openclaw cron` CLI so it can run on a host without direct Gateway RPC access.
|
|
*/
|
|
|
|
import { spawnSync as runProcessSync } 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 DEFAULT_TZ = "UTC";
|
|
const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily
|
|
const PERSISTED_ENV_KEYS = [
|
|
"PROMPTSEC_EMAIL_TO",
|
|
"PROMPTSEC_GIT_PULL",
|
|
"OPENCLAW_AUDIT_CONFIG",
|
|
"PROMPTSEC_SENDMAIL_BIN",
|
|
"PROMPTSEC_SMTP_HOST",
|
|
"PROMPTSEC_SMTP_PORT",
|
|
"PROMPTSEC_SMTP_HELO",
|
|
"PROMPTSEC_SMTP_FROM",
|
|
];
|
|
|
|
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 = runProcessSync(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 buildRunnerEnv({ hostLabel, emailTo }) {
|
|
const envVars = {
|
|
PROMPTSEC_HOST_LABEL: hostLabel,
|
|
};
|
|
|
|
if (emailTo) {
|
|
envVars.PROMPTSEC_EMAIL_TO = emailTo;
|
|
}
|
|
|
|
for (const key of PERSISTED_ENV_KEYS) {
|
|
const value = envOrEmpty(key);
|
|
if (value) {
|
|
envVars[key] = value;
|
|
}
|
|
}
|
|
|
|
return envVars;
|
|
}
|
|
|
|
function buildRunnerCommand({ installDir, hostLabel, emailTo }) {
|
|
const envVars = buildRunnerEnv({ hostLabel, emailTo });
|
|
const exports = Object.entries(envVars)
|
|
.filter(([, value]) => String(value ?? "").trim() !== "")
|
|
.map(([key, value]) => `${key}="${escapeForShellEnvVar(value)}"`);
|
|
|
|
const exportPrefix = exports.length ? `${exports.join(" ")} ` : "";
|
|
return `cd "${escapeForShellEnvVar(installDir || "")}" && ${exportPrefix}./scripts/runner.sh`;
|
|
}
|
|
|
|
function printPreflightSummary({ dmChannel, dmTo, emailTo, installDir, tz, hostLabel }) {
|
|
const emailSummary = emailTo || "disabled (set PROMPTSEC_EMAIL_TO to enable)";
|
|
const persistedKeys = Array.from(new Set([
|
|
"PROMPTSEC_HOST_LABEL",
|
|
emailTo ? "PROMPTSEC_EMAIL_TO" : null,
|
|
...PERSISTED_ENV_KEYS.filter((key) => envOrEmpty(key)),
|
|
].filter(Boolean)));
|
|
|
|
const lines = [
|
|
"Preflight review:",
|
|
"- This setup creates or updates an unattended openclaw cron job.",
|
|
"- Required runtime: openclaw CLI, node, bash.",
|
|
"- Optional email runtime: local sendmail or PROMPTSEC_SMTP_HOST/PROMPTSEC_SMTP_PORT relay.",
|
|
`- DM target: ${oneline(dmChannel)}:${oneline(dmTo)}`,
|
|
`- Email target: ${oneline(emailSummary)}`,
|
|
`- Schedule: ${DEFAULT_EXPR} (${oneline(tz)})`,
|
|
`- Install dir: ${oneline(installDir)}`,
|
|
];
|
|
|
|
if (hostLabel) {
|
|
lines.push(`- Host label: ${oneline(hostLabel)}`);
|
|
}
|
|
|
|
if (persistedKeys.length) {
|
|
lines.push(`- Cron payload persists env: ${persistedKeys.join(", ")}`);
|
|
}
|
|
|
|
process.stdout.write(lines.join("\n") + "\n\n");
|
|
}
|
|
|
|
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, emailTo }) {
|
|
const runnerCommand = buildRunnerCommand({ installDir, hostLabel, emailTo });
|
|
const emailLine = emailTo
|
|
? `Email: ${oneline(emailTo)} (sendmail first, SMTP fallback if configured)`
|
|
: "Email: disabled unless PROMPTSEC_EMAIL_TO is set";
|
|
|
|
return [
|
|
"Run daily openclaw security audits and deliver report to the configured recipients.",
|
|
"",
|
|
"Dependencies:",
|
|
"- Required runtime: openclaw CLI, node, bash.",
|
|
"- Optional email runtime: local sendmail or PROMPTSEC_SMTP_HOST/PROMPTSEC_SMTP_PORT relay.",
|
|
"",
|
|
"Configured delivery:",
|
|
`Delivery DM: ${oneline(dmChannel)}:${oneline(dmTo)}`,
|
|
emailLine,
|
|
"",
|
|
"Execute:",
|
|
`- Run via exec: ${runnerCommand}`,
|
|
"",
|
|
"Output requirements:",
|
|
"- Print the report to stdout (cron deliver will DM it).",
|
|
"- If PROMPTSEC_EMAIL_TO is set, email the same report to that address; if email fails, append a NOTE line to stdout.",
|
|
"- Do not apply fixes automatically.",
|
|
].join("\n");
|
|
}
|
|
|
|
function buildDescription({ dmChannel, dmTo, emailTo }) {
|
|
const emailPart = emailTo ? `; email ${emailTo}` : "; email disabled unless configured";
|
|
return `Runs openclaw security audit daily and delivers to ${dmChannel}:${dmTo}${emailPart}.`;
|
|
}
|
|
|
|
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 emailToEnv = envOrEmpty("PROMPTSEC_EMAIL_TO");
|
|
|
|
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 emailTo = interactive
|
|
? await prompt("Optional email recipient (leave empty to disable email)", { defaultValue: emailToEnv })
|
|
: emailToEnv;
|
|
|
|
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`);
|
|
}
|
|
|
|
printPreflightSummary({ dmChannel, dmTo, emailTo, installDir, tz, hostLabel });
|
|
|
|
const listOut = sh("openclaw", ["cron", "list", "--json"]);
|
|
const listJson = JSON.parse(listOut);
|
|
const existingId = findExistingJobId(listJson);
|
|
|
|
const agentMessage = buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir, emailTo });
|
|
const description = buildDescription({ dmChannel, dmTo, emailTo });
|
|
|
|
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);
|
|
});
|