ktg-plugin-marketplace/plugins/llm-security/scanners/watch-cron.mjs

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();