ktg-plugin-marketplace/plugins/llm-security/scanners/reference-config-generator.mjs

373 lines
13 KiB
JavaScript

#!/usr/bin/env node
// reference-config-generator.mjs — Generate Grade A security configuration
// Runs posture-scanner, identifies gaps, generates settings/CLAUDE.md/.gitignore
// to close them. Supports --dry-run (default) and --apply (writes files with backup).
//
// Standalone CLI: node scanners/reference-config-generator.mjs [path] [--apply] [--dry-run]
// Library: import { generate } from './reference-config-generator.mjs'
//
// Zero external dependencies — Node.js builtins only.
import { readFile, writeFile, mkdir, access, copyFile } from 'node:fs/promises';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scan } from './posture-scanner.mjs';
import { resetCounter } from './lib/output.mjs';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = resolve(__dirname, '..', 'templates', 'reference-config');
// Categories where we can generate config fixes
const FIXABLE_CATEGORIES = new Map([
[1, 'settings'], // Deny-First Configuration
[2, 'gitignore'], // Secrets Protection (gitignore portion)
[3, 'gitignore'], // Path Guarding (gitignore portion)
[6, 'settings'], // Sandbox Configuration
[10, 'claudemd'], // Cognitive State Security (CLAUDE.md guardrails)
]);
// ---------------------------------------------------------------------------
// File helpers
// ---------------------------------------------------------------------------
async function readJson(filePath) {
try {
return JSON.parse(await readFile(filePath, 'utf-8'));
} catch { return null; }
}
async function readText(filePath) {
try { return await readFile(filePath, 'utf-8'); }
catch { return null; }
}
async function fileExists(filePath) {
try { await access(filePath); return true; }
catch { return false; }
}
async function ensureDir(dirPath) {
await mkdir(dirPath, { recursive: true });
}
// ---------------------------------------------------------------------------
// Project type detection
// ---------------------------------------------------------------------------
/**
* Detect project type: plugin, monorepo, or standalone.
* @param {string} projectRoot
* @returns {Promise<'plugin' | 'monorepo' | 'standalone'>}
*/
async function detectProjectType(projectRoot) {
// Plugin: has .claude-plugin/plugin.json or plugin.json
if (await fileExists(join(projectRoot, '.claude-plugin', 'plugin.json'))) return 'plugin';
if (await fileExists(join(projectRoot, 'plugin.json'))) return 'plugin';
// Monorepo: package.json with workspaces, or lerna.json, or pnpm-workspace.yaml
const pkg = await readJson(join(projectRoot, 'package.json'));
if (pkg?.workspaces) return 'monorepo';
if (await fileExists(join(projectRoot, 'lerna.json'))) return 'monorepo';
if (await fileExists(join(projectRoot, 'pnpm-workspace.yaml'))) return 'monorepo';
return 'standalone';
}
// ---------------------------------------------------------------------------
// Recommendation builders
// ---------------------------------------------------------------------------
/**
* Build settings.json recommendation.
* If existing settings have deny-first, returns 'none'. Otherwise creates or merges.
*/
async function buildSettingsRec(projectRoot, categories) {
const settingsPath = join(projectRoot, '.claude', 'settings.json');
const existing = await readJson(settingsPath);
const template = await readJson(join(TEMPLATES_DIR, 'settings-deny-first.json'));
// Check if deny-first already set
const cat1 = categories.find(c => c.id === 1);
const cat6 = categories.find(c => c.id === 6);
const needsDenyFirst = cat1 && cat1.status !== 'PASS';
const needsSandboxFix = cat6 && cat6.status !== 'PASS' && cat6.status !== 'N_A';
if (!needsDenyFirst && !needsSandboxFix) {
return { category: 'Deny-First + Sandbox', file: '.claude/settings.json', action: 'none', content: '' };
}
if (!existing) {
// Create fresh from template
return {
category: 'Deny-First + Sandbox',
file: '.claude/settings.json',
action: 'create',
content: JSON.stringify(template, null, 2),
};
}
// Merge: preserve existing keys, add deny-first if missing
const merged = { ...existing };
if (needsDenyFirst) {
if (!merged.permissions) merged.permissions = {};
if (merged.permissions.defaultPermissionLevel !== 'deny' && merged.permissions.defaultPermissionLevel !== 'deny-all') {
merged.permissions.defaultPermissionLevel = 'deny';
}
if (!merged.permissions.allow || merged.permissions.allow.includes('*')) {
merged.permissions.allow = template.permissions.allow;
}
}
if (needsSandboxFix) {
if (merged.skipDangerousModePermissionPrompt === true) {
merged.skipDangerousModePermissionPrompt = false;
}
if (merged.dangerouslyAllowArbitraryPaths === true) {
delete merged.dangerouslyAllowArbitraryPaths;
}
}
return {
category: 'Deny-First + Sandbox',
file: '.claude/settings.json',
action: 'merge',
content: JSON.stringify(merged, null, 2),
};
}
/**
* Build CLAUDE.md recommendation.
* Appends security section if not already present.
*/
async function buildClaudeMdRec(projectRoot, categories) {
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
const existing = await readText(claudeMdPath);
const template = await readText(join(TEMPLATES_DIR, 'claude-md-security-section.md'));
// Check categories that benefit from CLAUDE.md guardrails
const cat1 = categories.find(c => c.id === 1);
const cat7 = categories.find(c => c.id === 7);
const cat10 = categories.find(c => c.id === 10);
// If already has security boundaries, skip
if (existing && /security\s+boundar/i.test(existing)) {
return { category: 'CLAUDE.md Security', file: 'CLAUDE.md', action: 'none', content: '' };
}
const needsSection = (cat1 && cat1.status !== 'PASS') ||
(cat7 && cat7.status !== 'PASS') ||
(cat10 && cat10.status !== 'PASS') ||
!existing;
if (!needsSection) {
return { category: 'CLAUDE.md Security', file: 'CLAUDE.md', action: 'none', content: '' };
}
if (!existing) {
return {
category: 'CLAUDE.md Security',
file: 'CLAUDE.md',
action: 'create',
content: `# Project\n\n${template}`,
};
}
return {
category: 'CLAUDE.md Security',
file: 'CLAUDE.md',
action: 'append',
content: `\n${template}`,
};
}
/**
* Build .gitignore recommendation.
* Adds missing security patterns.
*/
async function buildGitignoreRec(projectRoot, categories) {
const gitignorePath = join(projectRoot, '.gitignore');
const existing = await readText(gitignorePath);
const template = await readText(join(TEMPLATES_DIR, 'gitignore-security.txt'));
const templateLines = template.trim().split('\n').filter(l => l.trim() && !l.startsWith('#'));
const cat2 = categories.find(c => c.id === 2);
const cat9 = categories.find(c => c.id === 9);
if (!existing) {
const needsGitignore = (cat2 && cat2.status !== 'PASS') ||
(cat9 && cat9.status !== 'PASS');
if (!needsGitignore) {
return { category: 'Secrets + Session', file: '.gitignore', action: 'none', content: '' };
}
return {
category: 'Secrets + Session',
file: '.gitignore',
action: 'create',
content: template,
};
}
// Find missing lines
const missingLines = templateLines.filter(line => !existing.includes(line.trim()));
if (missingLines.length === 0) {
return { category: 'Secrets + Session', file: '.gitignore', action: 'none', content: '' };
}
return {
category: 'Secrets + Session',
file: '.gitignore',
action: 'append',
content: '\n# Security additions (llm-security harden)\n' + missingLines.join('\n') + '\n',
};
}
// ---------------------------------------------------------------------------
// Apply logic
// ---------------------------------------------------------------------------
/**
* Apply recommendations to the filesystem.
* @param {string} projectRoot
* @param {object[]} recommendations
* @returns {Promise<string|null>} backupPath or null
*/
async function applyRecommendations(projectRoot, recommendations) {
const actionable = recommendations.filter(r => r.action !== 'none');
if (actionable.length === 0) return null;
// Create backup of files we'll modify
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupDir = join(projectRoot, `.security-harden-backup-${ts}`);
let backedUp = false;
for (const rec of actionable) {
const filePath = join(projectRoot, rec.file);
if (await fileExists(filePath)) {
if (!backedUp) {
await ensureDir(backupDir);
backedUp = true;
}
const backupFile = join(backupDir, rec.file.replace(/\//g, '__'));
await copyFile(filePath, backupFile);
}
}
// Apply each recommendation
for (const rec of actionable) {
const filePath = join(projectRoot, rec.file);
switch (rec.action) {
case 'create': {
await ensureDir(dirname(filePath));
await writeFile(filePath, rec.content, 'utf-8');
break;
}
case 'merge': {
// For settings.json: write the merged content
await ensureDir(dirname(filePath));
await writeFile(filePath, rec.content, 'utf-8');
break;
}
case 'append': {
const existing = await readText(filePath) || '';
await writeFile(filePath, existing + rec.content, 'utf-8');
break;
}
}
}
return backedUp ? backupDir : null;
}
// ---------------------------------------------------------------------------
// Main generate function
// ---------------------------------------------------------------------------
/**
* Generate reference configuration for a project.
* @param {string} targetPath - Absolute path to project root
* @param {object} [options]
* @param {boolean} [options.apply=false] - Write files to disk
* @param {boolean} [options.dryRun=true] - Show what would change (default)
* @returns {Promise<object>} Generation result
*/
export async function generate(targetPath, options = {}) {
const apply = options.apply === true;
const startMs = Date.now();
const projectRoot = resolve(targetPath);
// Step 1: Detect project type
const projectType = await detectProjectType(projectRoot);
// Step 2: Run posture scan
resetCounter();
const postureResult = await scan(projectRoot);
const categories = postureResult.categories;
// Step 3: Build recommendations
const recommendations = [];
recommendations.push(await buildSettingsRec(projectRoot, categories));
recommendations.push(await buildClaudeMdRec(projectRoot, categories));
recommendations.push(await buildGitignoreRec(projectRoot, categories));
// Step 4: Apply if requested
let backupPath = null;
let applied = false;
if (apply) {
backupPath = await applyRecommendations(projectRoot, recommendations);
applied = true;
}
const durationMs = Date.now() - startMs;
return {
status: 'ok',
target: projectRoot,
projectType,
timestamp: new Date().toISOString(),
duration_ms: durationMs,
posture: {
grade: postureResult.scoring.grade,
pass_rate: postureResult.scoring.pass_rate,
pass: postureResult.scoring.pass,
partial: postureResult.scoring.partial,
fail: postureResult.scoring.fail,
},
recommendations,
applied,
backupPath,
summary: {
total: recommendations.length,
actionable: recommendations.filter(r => r.action !== 'none').length,
creates: recommendations.filter(r => r.action === 'create').length,
merges: recommendations.filter(r => r.action === 'merge').length,
appends: recommendations.filter(r => r.action === 'append').length,
},
};
}
// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
const args = process.argv.slice(2);
const applyFlag = args.includes('--apply');
const pathArg = args.find(a => !a.startsWith('--')) || process.cwd();
const absTarget = resolve(pathArg);
try {
const result = await generate(absTarget, { apply: applyFlag });
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(0);
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(2);
}
}