#!/usr/bin/env node 'use strict'; /** * verify-clue-fingerprints.js * Verifies that clue_fingerprint evidence in quest files is detectable * in the actual VM state. * * Usage (run from project root): * node tools/content/verify-clue-fingerprints.js * node tools/content/verify-clue-fingerprints.js --quest Q002 * node tools/content/verify-clue-fingerprints.js --revert * node tools/content/verify-clue-fingerprints.js --start-vms * node tools/content/verify-clue-fingerprints.js --dry-run * * Flags: * --quest Check only the specified quest (e.g. Q002) * --revert Revert each VM to its quest baseline snapshot before checking. * Leaves VMs running at baseline afterwards (ready for play). * --start-vms Start VMs that are not running (without reverting snapshot). * Use after a fresh boot if VMs are just stopped, not reverted. * --dry-run Print the SSH commands that would run without executing them. * --help Show this help text. * * Exit codes: * 0 All evidence confirmed (or dry-run) * 1 One or more failures, or no checks ran * * Prerequisites: * - virsh available and pointing at qemu:///system * - ~/.ssh/sc_host_key present * - VMs running (or --revert / --start-vms provided) */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const os = require('os'); // --------------------------------------------------------------------------- // CONFIG // --------------------------------------------------------------------------- const PROJECT_ROOT = path.resolve(__dirname, '../..'); const CONTENT_ROOT = path.join(PROJECT_ROOT, 'content'); const QUESTS_DIR = path.join(CONTENT_ROOT, 'quests'); const VM_PROFILES_DIR = path.join(CONTENT_ROOT, 'vm_profiles'); const SSH_KEY = path.join(os.homedir(), '.ssh/sc_host_key'); const LIBVIRT_URI = 'qemu:///system'; const VM_PREFIX = 'sc-'; const SSH_WAIT_SECS = 90; // max seconds to wait for SSH after revert/start const SSH_POLL_SECS = 5; const SSH_OPTS = [ '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-o', 'LogLevel=ERROR', '-i', SSH_KEY, ]; // --------------------------------------------------------------------------- // ARGS // --------------------------------------------------------------------------- const rawArgs = process.argv.slice(2); const dryRun = rawArgs.includes('--dry-run'); const doRevert = rawArgs.includes('--revert'); const startVms = rawArgs.includes('--start-vms'); const showHelp = rawArgs.includes('--help') || rawArgs.includes('-h'); const questIdx = rawArgs.indexOf('--quest'); const questFilter = questIdx >= 0 ? rawArgs[questIdx + 1] : null; if (showHelp) { const usage = fs.readFileSync(__filename, 'utf8').split('\n') .slice(1, 20).map(l => l.replace(/^ \* ?/, '')).join('\n'); console.log(usage); process.exit(0); } // --------------------------------------------------------------------------- // VIRSH HELPERS // --------------------------------------------------------------------------- function virsh(...args) { const r = spawnSync('virsh', ['--connect', LIBVIRT_URI, ...args], { encoding: 'utf8', timeout: 20000, }); return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), }; } function domainName(vmId) { return VM_PREFIX + vmId.replace(/_/g, '-'); } function vmIsRunning(vmId) { const r = virsh('domstate', domainName(vmId)); return r.ok && r.stdout === 'running'; } function getVmIp(vmId) { const domain = domainName(vmId); // Primary: guest agent const r = virsh('domifaddr', domain, '--source', 'agent'); if (r.ok && r.stdout) { const m = r.stdout.match(/(\d+\.\d+\.\d+\.\d+)/); if (m) return m[1]; } // Fallback: DHCP leases table const leases = virsh('net-dhcp-leases', 'sc-internal'); if (leases.ok && leases.stdout) { const hostname = domain.replace(VM_PREFIX, ''); for (const line of leases.stdout.split('\n')) { if (line.includes(hostname)) { const m = line.match(/(\d+\.\d+\.\d+\.\d+)/); if (m) return m[1]; } } } return null; } function sleep(secs) { spawnSync('sleep', [String(secs)]); } function waitForSsh(vmId, user) { process.stdout.write(` Waiting for SSH on ${domainName(vmId)}`); const deadline = Date.now() + SSH_WAIT_SECS * 1000; while (Date.now() < deadline) { const ip = getVmIp(vmId); if (ip) { const r = spawnSync('ssh', [...SSH_OPTS, '-o', 'ConnectTimeout=5', `${user}@${ip}`, 'true'], { encoding: 'utf8', timeout: 8000, }); if (r.status === 0) { console.log(` ready (${ip})`); return ip; } } process.stdout.write('.'); sleep(SSH_POLL_SECS); } console.log(' TIMEOUT'); return null; } function revertVmToBaseline(vmId, snapshot) { const domain = domainName(vmId); console.log(` Reverting ${domain} → ${snapshot}...`); // Destroy (force-stop) if running — state will be discarded if (vmIsRunning(vmId)) { virsh('destroy', domain); sleep(1); } const r = virsh('snapshot-revert', domain, snapshot, '--running'); if (!r.ok) { console.error(` ERROR reverting ${domain}: ${r.stderr}`); return false; } return true; } // --------------------------------------------------------------------------- // CONTENT LOADING // --------------------------------------------------------------------------- function loadVmProfiles() { const profiles = {}; for (const f of fs.readdirSync(VM_PROFILES_DIR)) { if (!f.endsWith('.json')) continue; const d = JSON.parse(fs.readFileSync(path.join(VM_PROFILES_DIR, f), 'utf8')); profiles[d.id] = d; } return profiles; } function loadQuests() { return fs.readdirSync(QUESTS_DIR) .filter(f => f.endsWith('.json') && !f.includes('SPLIT')) .map(f => { try { return JSON.parse(fs.readFileSync(path.join(QUESTS_DIR, f), 'utf8')); } catch { return null; } }) .filter(Boolean) .sort((a, b) => (a.id < b.id ? -1 : 1)); } // --------------------------------------------------------------------------- // EVIDENCE CHECK COMMAND BUILDER // --------------------------------------------------------------------------- function sq(s) { // Shell-quote a value for embedding in a bash command string return "'" + String(s).replace(/'/g, "'\"'\"'") + "'"; } function buildCmd(ev) { // Returns a bash one-liner that prints "PASS" or "FAIL [detail]" // All commands run via sudo to handle restricted paths. switch (ev.type) { case 'file_absent': return `sudo test ! -e ${sq(ev.path)} && echo PASS || echo 'FAIL (file exists)'`; case 'file_exists': return `sudo test -e ${sq(ev.path)} && echo PASS || echo 'FAIL (file missing)'`; case 'file_contains': case 'log_contains': { const pat = sq(ev.contains); return `sudo grep -qF ${pat} ${sq(ev.path)} 2>/dev/null && echo PASS || echo 'FAIL (pattern not found)'`; } case 'file_owner_is_not': { const notExpected = ev.expected_user || ev.expected_owner || ''; return ( `owner=$(sudo stat -c '%U' ${sq(ev.path)} 2>/dev/null || echo '?'); ` + `[ "\${owner}" != ${sq(notExpected)} ] && echo PASS || ` + `echo "FAIL (owner=\${owner}, expected NOT ${notExpected})"` ); } case 'file_size_above': { const threshold = ev.threshold_bytes; return ( `bytes=$(sudo stat -c '%s' ${sq(ev.path)} 2>/dev/null || echo 0); ` + `[ "\${bytes}" -gt ${threshold} ] && echo PASS || ` + `echo "FAIL (size=\${bytes} bytes, threshold=${threshold})"` ); } case 'disk_usage_above': { const threshold = ev.threshold_percent; return ( `pct=$(sudo df --output=pcent ${sq(ev.path)} 2>/dev/null | tail -1 | tr -d ' %'); ` + `[ "\${pct}" -gt ${threshold} ] && echo PASS || ` + `echo "FAIL (usage=\${pct}%, threshold=${threshold}%)"` ); } case 'service_state_is': { const expected = ev.state; return ( `state=$(sudo systemctl is-active ${sq(ev.service)} 2>/dev/null || echo inactive); ` + `[ "\${state}" = ${sq(expected)} ] && echo PASS || ` + `echo "FAIL (state=\${state}, expected=${expected})"` ); } case 'service_enabled_is': { const wantEnabled = ev.enabled === true || ev.enabled === 'true'; return ( `estate=$(sudo systemctl is-enabled ${sq(ev.service)} 2>/dev/null || echo disabled); ` + (wantEnabled ? `[ "\${estate}" = "enabled" ] && echo PASS || echo "FAIL (is-enabled=\${estate}, expected=enabled)"` : `[ "\${estate}" != "enabled" ] && echo PASS || echo "FAIL (is-enabled=\${estate}, expected=disabled)"`) ); } default: return null; } } function describeEvidence(ev) { switch (ev.type) { case 'file_absent': return `file absent: ${ev.path}`; case 'file_exists': return `file exists: ${ev.path}`; case 'file_contains': case 'log_contains': return `${ev.path} ∋ ${JSON.stringify(ev.contains)}`; case 'file_owner_is_not': return `${ev.path} owner ≠ ${ev.expected_user || ev.expected_owner}`; case 'file_size_above': return `${ev.path} > ${(ev.threshold_bytes / 1e9).toFixed(1)} GB`; case 'disk_usage_above': return `${ev.path} disk > ${ev.threshold_percent}%`; case 'service_state_is': return `${ev.service} state = ${ev.state}`; case 'service_enabled_is': return `${ev.service} enabled = ${ev.enabled}`; default: return `${ev.type}: ${JSON.stringify(ev)}`; } } // --------------------------------------------------------------------------- // MAIN // --------------------------------------------------------------------------- function main() { console.log('\n Sysadmin Chronicles — Clue Fingerprint Verifier'); console.log('═══════════════════════════════════════\n'); if (dryRun) console.log(' Mode: DRY-RUN (no SSH commands executed)\n'); if (doRevert) console.log(' Mode: REVERT — VMs will be reverted to baseline snapshots\n'); const vmProfiles = loadVmProfiles(); let quests = loadQuests(); if (questFilter) { quests = quests.filter(q => q.id === questFilter); if (quests.length === 0) { console.error(`No quest found with id: ${questFilter}`); process.exit(1); } } // Collect VMs needed (evidence items might reference multiple VMs, e.g. Q008) const vmsNeeded = new Set(); for (const q of quests) { for (const ev of (q.clue_fingerprint?.evidence || [])) { if (ev.vm) vmsNeeded.add(ev.vm); } } // Resolve IPs for non-revert mode upfront (revert mode resolves per-quest) const vmIps = {}; if (!doRevert) { for (const vmId of vmsNeeded) { const domain = domainName(vmId); const profile = vmProfiles[vmId]; const user = profile?.management_user || profile?.ssh_user || 'player'; if (!vmIsRunning(vmId)) { if (startVms) { console.log(`Starting ${domain}...`); virsh('start', domain); vmIps[vmId] = waitForSsh(vmId, user); } else { console.warn(` ⚠ ${domain} is not running. Use --start-vms or --revert.`); vmIps[vmId] = null; } } else { process.stdout.write(` Resolving IP for ${domain}...`); let ip = null; const deadline = Date.now() + 30000; while (!ip && Date.now() < deadline) { ip = getVmIp(vmId); if (!ip) { process.stdout.write('.'); sleep(3); } } console.log(ip ? ` ${ip}` : ' (could not resolve)'); vmIps[vmId] = ip; } } } // --------------------------------------------------------------------------- // Per-quest checks // --------------------------------------------------------------------------- let totalPass = 0, totalFail = 0, totalSkip = 0; const failures = []; for (const quest of quests) { const evidence = quest.clue_fingerprint?.evidence || []; if (evidence.length === 0) { console.log(`\n[${quest.id}] ${quest.title} — no evidence entries, skipping`); continue; } const baseline = quest.baseline_snapshot || '(unknown)'; const primaryVm = quest.primary_vm || ''; console.log(`\n[${quest.id}] ${quest.title}`); console.log(` Baseline: ${primaryVm} @ ${baseline}`); // Revert primary VM if --revert if (doRevert && primaryVm && !dryRun) { const revOk = revertVmToBaseline(primaryVm, baseline); if (!revOk) { console.log(` ✗ Cannot revert — skipping ${evidence.length} checks`); totalSkip += evidence.length; continue; } const profile = vmProfiles[primaryVm]; const user = profile?.management_user || profile?.ssh_user || 'player'; const ip = waitForSsh(primaryVm, user); vmIps[primaryVm] = ip; if (!ip) { console.log(` ✗ SSH timeout after revert — skipping ${evidence.length} checks`); totalSkip += evidence.length; continue; } } // Also handle multi-VM quests (Q008 references both web_server and build_machine) const questVms = new Set(evidence.map(ev => ev.vm).filter(Boolean)); if (doRevert) { for (const extraVm of questVms) { if (extraVm === primaryVm) continue; if (!vmIps[extraVm]) { // Extra VM: just ensure it's running (don't revert — only primary gets reverted) if (!vmIsRunning(extraVm)) { virsh('start', domainName(extraVm)); } const profile = vmProfiles[extraVm]; const user = profile?.management_user || profile?.ssh_user || 'player'; vmIps[extraVm] = waitForSsh(extraVm, user); } } } // Run each evidence check for (const ev of evidence) { const vmId = ev.vm || primaryVm; const ip = vmIps[vmId]; const profile = vmProfiles[vmId]; const user = profile?.management_user || profile?.ssh_user || 'player'; const desc = describeEvidence(ev); if (!ip && !dryRun) { console.log(` ⚠ SKIP ${desc}`); console.log(` → VM ${vmId} not reachable`); totalSkip++; continue; } const cmd = buildCmd(ev); if (!cmd) { console.log(` ⚠ SKIP ${desc}`); console.log(` → evidence type '${ev.type}' not supported by this tool`); totalSkip++; continue; } let passed; let detail = ''; if (dryRun) { console.log(` ○ DRY ${desc}`); console.log(` → ssh ${user}@<${vmId}-ip> '${cmd.slice(0, 80)}${cmd.length > 80 ? '...' : ''}'`); totalPass++; continue; } const r = spawnSync('ssh', [...SSH_OPTS, `${user}@${ip}`, cmd], { encoding: 'utf8', timeout: 25000, }); const stdout = (r.stdout || '').trim(); const stderr = (r.stderr || '').trim(); if (r.error || r.status === null) { passed = false; detail = r.error ? r.error.message : 'SSH process failed'; } else { passed = stdout.startsWith('PASS'); detail = stdout.startsWith('FAIL') ? stdout.replace(/^FAIL\s*/, '') : (stderr || `exit ${r.status}`); } if (passed) { console.log(` ✓ PASS ${desc}`); totalPass++; } else { console.log(` ✗ FAIL ${desc}`); if (detail) console.log(` → ${detail}`); totalFail++; failures.push({ quest: quest.id, desc, detail }); } } } // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- console.log('\n═══════════════════════════════════════'); console.log(` Results: ${totalPass} passed ${totalFail} failed ${totalSkip} skipped`); if (totalFail > 0) { console.log('\n Failed checks:'); for (const f of failures) { console.log(` ✗ [${f.quest}] ${f.desc}`); if (f.detail) console.log(` → ${f.detail}`); } console.log('\n ✗ Clue fingerprint verification FAILED'); console.log('═══════════════════════════════════════\n'); process.exit(1); } if (!dryRun && totalPass === 0 && totalSkip > 0) { console.log('\n ⚠ No checks ran. Are the VMs running?'); console.log(' Tips:'); console.log(' --start-vms start VMs then check on current state'); console.log(' --revert revert to baseline snapshot then check'); console.log('═══════════════════════════════════════\n'); process.exit(1); } if (dryRun) { console.log('\n Dry-run complete. No checks were executed.'); } else { console.log('\n ✓ All evidence confirmed in VM state'); } console.log('═══════════════════════════════════════\n'); process.exit(0); } main();