#!/usr/bin/env node // watch-cron.mjs — Standalone cron wrapper for continuous security scanning // // Usage: node watch-cron.mjs [--config ] // Config: reports/watch/config.json (created on first run) // Output: reports/watch/latest.json // Exit: 0 = all ALLOW, 1 = any WARNING, 2 = any BLOCK import { resolve, join, dirname, basename } from 'node:path'; import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = dirname(__dirname); const ORCHESTRATOR = join(__dirname, 'scan-orchestrator.mjs'); const DEFAULT_CONFIG = join(PLUGIN_ROOT, 'reports', 'watch', 'config.json'); const WATCH_DIR = join(PLUGIN_ROOT, 'reports', 'watch'); const SCAN_TIMEOUT = 300_000; // 5 minutes per target // --- Config --- const CONFIG_TEMPLATE = { targets: [ { path: '/absolute/path/to/project', label: 'my-project' } ], options: { baseline: true, saveBaseline: true } }; function parseArgs(argv) { const args = { config: DEFAULT_CONFIG }; for (let i = 2; i < argv.length; i++) { if (argv[i] === '--config' && argv[i + 1]) { args.config = resolve(argv[++i]); } } return args; } function loadConfig(configPath) { if (!existsSync(configPath)) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify(CONFIG_TEMPLATE, null, 2) + '\n'); console.log(`No watch config found. Created template at:\n ${configPath}\n`); console.log('Edit it to add your watch targets (use absolute paths), then re-run.'); return null; } try { const config = JSON.parse(readFileSync(configPath, 'utf8')); if (!Array.isArray(config.targets) || config.targets.length === 0) { console.log('Watch config has no targets. Add at least one target to config.targets[].'); return null; } return config; } catch (err) { console.error(`Failed to parse config: ${err.message}`); return null; } } // --- Scan Execution --- function runScan(target, options, pluginRoot) { const label = target.label || basename(target.path); const tmpFile = join(tmpdir(), `llm-security-watch-${Date.now()}-${label}.json`); const args = [ORCHESTRATOR, target.path, '--output-file', tmpFile]; if (options.baseline !== false) args.push('--baseline'); if (options.saveBaseline !== false) args.push('--save-baseline'); const result = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: SCAN_TIMEOUT, cwd: pluginRoot }); const entry = { path: target.path, label, verdict: null, risk_score: null, counts: null, diff: null, error: null, exit_code: result.status }; if (result.error) { entry.error = result.error.code === 'ETIMEDOUT' ? 'timeout' : result.error.message; return entry; } // Parse compact aggregate from stdout (when --output-file is used) try { const stdout = JSON.parse(result.stdout); if (stdout.aggregate) { entry.verdict = stdout.aggregate.verdict; entry.risk_score = stdout.aggregate.risk_score; entry.counts = stdout.aggregate.counts; } } catch { // stdout may be empty or non-JSON on error } // Read full output for diff data try { if (existsSync(tmpFile)) { const full = JSON.parse(readFileSync(tmpFile, 'utf8')); if (full.diff) { entry.diff = { new: full.diff.summary?.new ?? 0, resolved: full.diff.summary?.resolved ?? 0, unchanged: full.diff.summary?.unchanged ?? 0, moved: full.diff.summary?.moved ?? 0 }; } } } catch { // diff data is optional } // Cleanup temp file try { if (existsSync(tmpFile)) unlinkSync(tmpFile); } catch { // best effort } return entry; } // --- Summary --- function buildSummary(results, startTime) { const verdictRank = { ALLOW: 0, WARNING: 1, BLOCK: 2 }; let worst = 'ALLOW'; for (const r of results) { if (r.verdict && verdictRank[r.verdict] > verdictRank[worst]) { worst = r.verdict; } } return { meta: { timestamp: new Date().toISOString(), duration_ms: Date.now() - startTime, targets_scanned: results.length, targets_ok: results.filter(r => !r.error).length, targets_failed: results.filter(r => r.error).length }, worst_verdict: worst, targets: results }; } function formatStdout(summary) { const lines = [`[llm-security watch] ${summary.meta.timestamp}`]; for (const t of summary.targets) { if (t.error) { lines.push(` ${t.label}: ERROR (${t.error})`); } else { const score = t.risk_score != null ? `score ${t.risk_score}` : 'no score'; let delta = 'baseline scan'; if (t.diff) { const parts = []; if (t.diff.new > 0) parts.push(`${t.diff.new} new`); if (t.diff.resolved > 0) parts.push(`${t.diff.resolved} resolved`); delta = parts.length > 0 ? parts.join(', ') : 'no changes'; } lines.push(` ${t.label}: ${t.verdict} (${score}) — ${delta}`); } } const outputPath = join(WATCH_DIR, 'latest.json'); lines.push(`Worst: ${summary.worst_verdict} | Output: ${outputPath}`); return lines.join('\n'); } // --- Main --- function main() { const args = parseArgs(process.argv); const config = loadConfig(args.config); if (!config) process.exit(0); if (!existsSync(ORCHESTRATOR)) { console.error(`Scan orchestrator not found: ${ORCHESTRATOR}`); process.exit(1); } const options = config.options || {}; const startTime = Date.now(); const results = []; for (const target of config.targets) { if (!target.path) { console.error(' Skipping target with no path'); continue; } results.push(runScan(target, options, PLUGIN_ROOT)); } const summary = buildSummary(results, startTime); // Write output mkdirSync(WATCH_DIR, { recursive: true }); writeFileSync(join(WATCH_DIR, 'latest.json'), JSON.stringify(summary, null, 2) + '\n'); // Print to stdout console.log(formatStdout(summary)); // Exit with worst verdict code const exitCodes = { ALLOW: 0, WARNING: 1, BLOCK: 2 }; process.exit(exitCodes[summary.worst_verdict] || 0); } main();