feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
225
plugins/llm-security/scanners/watch-cron.mjs
Normal file
225
plugins/llm-security/scanners/watch-cron.mjs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
#!/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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue