373 lines
13 KiB
JavaScript
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);
|
|
}
|
|
}
|