225 lines
6.2 KiB
JavaScript
225 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// watch-cron.mjs — Standalone cron wrapper for continuous security scanning
|
|
//
|
|
// Usage: node watch-cron.mjs [--config <path>]
|
|
// 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();
|