daemon-sec-lotl

DÆMONBins: GTFOBins × LOLBAS × WADComs × LOOBins in one filterable catalog
git clone https://git.daemon-sec.xyz/daemon-sec-lotl.git
Log | Files | Refs | Submodules | README | LICENSE

build-dataset.mjs (24912B)


      1 /**
      2  * DÆMONBins dataset builder.
      3  *
      4  * Reads the three vendored upstreams under vendor/ plus the daemon-sec WADComs
      5  * additions (and, if present, the DÆMON modernization backlog), normalizes
      6  * every source to one filterable atom — a Technique — and emits:
      7  *
      8  *   src/data/techniques.json     full canonical array (committed; Astro imports it)
      9  *   src/data/tools.json          per-tool metadata (aliases, Full_Path, contributors)
     10  *   src/data/facets.json         derived facet indexes + counts
     11  *   public/data/techniques.json  copy served statically for the catalog island fetch
     12  *
     13  * Run locally with `npm run data`. CI never runs this — it builds Astro +
     14  * Pagefind over the committed JSON. Deterministic: IDs derive from
     15  * source + tool + index, no wall-clock.
     16  *
     17  * All three upstreams are GPL-3.0, so the merged dataset is GPL-3.0.
     18  */
     19 import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'node:fs';
     20 import { fileURLToPath } from 'node:url';
     21 import { dirname, join, resolve } from 'node:path';
     22 import yaml from 'js-yaml';
     23 import { enrich, stableId } from './enrich-data.mjs';
     24 import { impacketScript, canonicalizeFamilyCase } from './wadcoms-normalize.mjs';
     25 import { PLATFORMS, SOURCES, CAPABILITIES, validateTechniques } from './technique-schema.mjs';
     26 
     27 const __dirname = dirname(fileURLToPath(import.meta.url));
     28 const ROOT = resolve(__dirname, '..');
     29 const VENDOR = join(ROOT, 'vendor');
     30 const OUT_SRC = join(ROOT, 'src', 'data');
     31 const OUT_PUB = join(ROOT, 'public', 'data');
     32 // Source of truth for the 134 daemon-authored WADComs additions.
     33 const WADCOMS_JSON = join(OUT_SRC, 'sources', 'wadcoms-additions.json');
     34 // Optional: the §7 modernization backlog, emitted by the daemonbins-backlog workflow.
     35 const BACKLOG_JSON = join(OUT_SRC, 'daemon-backlog.json');
     36 
     37 // ---------------------------------------------------------------------------
     38 // Unified vocabulary
     39 // ---------------------------------------------------------------------------
     40 // PLATFORMS / SOURCES / CAPABILITIES + TechniqueSchema live in technique-schema.mjs
     41 // (shared with validate-data.mjs and the one-shot migrations).
     42 
     43 // GTFOBins function → capability (BUILD-PROMPT §5)
     44 const GTFO_FN_CAP = {
     45   shell: ['Execution'],
     46   command: ['Execution'],
     47   'reverse-shell': ['Reverse/Bind Shell'],
     48   'bind-shell': ['Reverse/Bind Shell'],
     49   'file-write': ['File Write'],
     50   'file-read': ['File Read'],
     51   upload: ['File Upload'],
     52   download: ['File Download'],
     53   'library-load': ['Library Load'],
     54   'privilege-escalation': ['Privilege Escalation'],
     55 };
     56 // GTFOBins function → MITRE fallback (from _data/functions.yml)
     57 const GTFO_FN_MITRE = {
     58   shell: ['T1059'], command: ['T1059'],
     59   'reverse-shell': ['T1059', 'T1071'], 'bind-shell': ['T1059', 'T1071'],
     60   'file-write': ['T1565'], 'file-read': ['T1005'],
     61   upload: ['T1041'], download: ['T1105'],
     62   'library-load': ['T1574'], 'privilege-escalation': ['T1548'],
     63 };
     64 
     65 // LOLBAS Category → capability (BUILD-PROMPT §5)
     66 const LOLBAS_CAT_CAP = {
     67   Execute: ['Execution'],
     68   'AWL Bypass': ['AWL / Policy Bypass'],
     69   Download: ['File Download'],
     70   Upload: ['File Upload'],
     71   Copy: ['File Copy'],
     72   Encode: ['Defense Evasion'],
     73   Decode: ['Defense Evasion'],
     74   ADS: ['Defense Evasion', 'File Write'],
     75   Conceal: ['Defense Evasion'],
     76   Tamper: ['Defense Evasion'],
     77   Compile: ['Compile'],
     78   Credentials: ['Credential Access'],
     79   Dump: ['Credential Access'],
     80   Reconnaissance: ['Discovery'],
     81   'UAC Bypass': ['UAC Bypass', 'Privilege Escalation'],
     82 };
     83 
     84 // WADComs attack_type → capability (BUILD-PROMPT §5). Kept forgiving: unknown
     85 // values simply don't add a capability, but are always kept in nativeCategory.
     86 const WAD_ATTACK_CAP = {
     87   Enumeration: ['Enumeration'],
     88   Discovery: ['Discovery'],
     89   Exploitation: ['Execution'],
     90   PrivEsc: ['Privilege Escalation'],
     91   'Privilege Escalation': ['Privilege Escalation'],
     92   Persistence: ['Persistence'],
     93   'Credential Access': ['Credential Access'],
     94   'Lateral Movement': ['Lateral Movement'],
     95   'Defense Evasion': ['Defense Evasion'],
     96   Collection: ['Collection'],
     97 };
     98 
     99 // Services / items that flag Active Directory scope (BUILD-PROMPT §5).
    100 const AD_SERVICES = new Set(['SMB', 'LDAP', 'Kerberos', 'RPC', 'WMI', 'DCOM', 'NTLM', 'DNS', 'WinRM', 'MSSQL', 'ADCS']);
    101 const AD_ITEMS = new Set(['TGS', 'TGT', 'PFX', 'AES_Key']);
    102 
    103 // GTFOBins binaries that are also standard on macOS (the classic POSIX/BSD
    104 // userland). Tagged Linux+macOS; everything else stays Linux-only rather than
    105 // risk a false macOS claim.
    106 const GTFO_MACOS = new Set([
    107   'awk', 'base64', 'basename', 'bash', 'bridge', 'busybox', 'cat', 'chmod', 'chown', 'cp',
    108   'csh', 'cut', 'date', 'dd', 'df', 'diff', 'dig', 'dmesg', 'du', 'ed', 'env', 'expand',
    109   'expect', 'file', 'find', 'flock', 'fmt', 'fold', 'ftp', 'gawk', 'gcc', 'gdb', 'gem',
    110   'genie', 'git', 'grep', 'gzip', 'head', 'hexdump', 'iconv', 'irb', 'jjs', 'join', 'jq',
    111   'ksh', 'ld.so', 'less', 'ln', 'logsave', 'look', 'lua', 'mail', 'make', 'man', 'more',
    112   'mv', 'nano', 'nawk', 'nc', 'nice', 'nl', 'nmap', 'node', 'nohup', 'od', 'openssl',
    113   'perl', 'pexec', 'php', 'pico', 'pip', 'python', 'python2', 'python3', 'rake', 'readelf',
    114   'ruby', 'run-parts', 'rvim', 'scp', 'screen', 'script', 'sed', 'setarch', 'sftp', 'sh',
    115   'smbclient', 'socat', 'sort', 'sqlite3', 'ss', 'ssh', 'stdbuf', 'strace', 'systemctl',
    116   'tac', 'tail', 'tar', 'taskset', 'tbl', 'tclsh', 'tcpdump', 'tee', 'telnet', 'tftp',
    117   'time', 'timeout', 'tmux', 'top', 'ul', 'unexpand', 'uniq', 'unshare', 'unzip', 'vi',
    118   'vim', 'watch', 'wc', 'wget', 'xargs', 'xxd', 'zip', 'zsh', 'zypper',
    119 ]);
    120 
    121 // ---------------------------------------------------------------------------
    122 // Helpers
    123 // ---------------------------------------------------------------------------
    124 const uniq = (a) => [...new Set(a.filter((x) => x != null && x !== ''))];
    125 const asArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]);
    126 
    127 function listFiles(dir) {
    128   if (!existsSync(dir)) return [];
    129   return readdirSync(dir).filter((f) => {
    130     try { return statSync(join(dir, f)).isFile(); } catch { return false; }
    131   });
    132 }
    133 
    134 function parseGtfoFile(content) {
    135   // Whole-file Jekyll front matter: strip leading `---` and trailing `...`.
    136   const body = content.replace(/^---\r?\n/, '').replace(/\r?\n?\.\.\.\s*$/, '');
    137   return yaml.load(body) || {};
    138 }
    139 
    140 function parseFrontMatter(content) {
    141   // Tolerate a leading BOM / blank lines before the `---` (some upstream
    142   // WADComs files, e.g. Rubeus-s4u.md, open with a blank line).
    143   const s = content.replace(/^/, '').replace(/^\s+/, '');
    144   const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(s);
    145   if (!m) return {};
    146   return yaml.load(m[1]) || {};
    147 }
    148 
    149 function parseMitreFromRefs(refs) {
    150   const out = [];
    151   for (const r of asArray(refs)) {
    152     const re = /attack\.mitre\.org\/techniques\/(T\d{4})(?:\/(\d{3}))?/gi;
    153     let m;
    154     while ((m = re.exec(String(r))) !== null) out.push(m[2] ? `${m[1]}.${m[2]}` : m[1]);
    155   }
    156   return uniq(out);
    157 }
    158 
    159 function normPrivilege(raw) {
    160   const s = String(raw || '').toLowerCase();
    161   if (!s) return undefined;
    162   if (/(system|nt authority|trustedinstaller)/.test(s)) return 'system';
    163   if (/(admin|elevated|high integrity|local administrator)/.test(s)) return 'admin';
    164   return 'user';
    165 }
    166 
    167 const lolbasUrlType = { OSBinaries: 'Binaries', OSLibraries: 'Libraries', OSScripts: 'Scripts', OtherMSBinaries: 'OtherMSBinaries' };
    168 const lolbasType = { OSBinaries: 'Binary', OSLibraries: 'Library', OSScripts: 'Script', OtherMSBinaries: 'OtherMSBinary' };
    169 
    170 
    171 // ---------------------------------------------------------------------------
    172 // GTFOBins
    173 // ---------------------------------------------------------------------------
    174 function ingestGtfobins() {
    175   const dir = join(VENDOR, 'gtfobins', '_gtfobins');
    176   const files = listFiles(dir);
    177   const techniques = [];
    178   const tools = new Map();
    179   const aliasEdges = []; // {from: aliasFile, to: canonical}
    180 
    181   // Pass 1 — parse every file into a map.
    182   const parsed = new Map();
    183   for (const bin of files) {
    184     const data = parseGtfoFile(readFileSync(join(dir, bin), 'utf8'));
    185     parsed.set(bin, data);
    186   }
    187 
    188   // Resolve the function set a binary exposes (for inherit capability union).
    189   const funcsOf = (bin, seen = new Set()) => {
    190     if (seen.has(bin)) return [];
    191     seen.add(bin);
    192     const d = parsed.get(bin);
    193     if (!d) return [];
    194     if (d.alias) return funcsOf(String(d.alias), seen);
    195     return Object.keys(d.functions || {}).filter((f) => f !== 'inherit');
    196   };
    197 
    198   for (const bin of files) {
    199     const data = parsed.get(bin);
    200     if (data.alias) { aliasEdges.push({ from: bin, to: String(data.alias) }); continue; }
    201     const fns = data.functions || {};
    202     const toolId = `gtfo:${bin}`;
    203     const pageUrl = `https://gtfobins.github.io/gtfobins/${bin}/`;
    204     const platform = GTFO_MACOS.has(bin) ? ['Linux', 'macOS'] : ['Linux'];
    205     if (!tools.has(toolId)) {
    206       tools.set(toolId, { id: toolId, name: bin, source: 'GTFOBins', platform, aliases: [], references: [pageUrl], count: 0 });
    207     }
    208 
    209     for (const [fn, examples] of Object.entries(fns)) {
    210       asArray(examples).forEach((ex, i) => {
    211         const contexts = ex.contexts && Object.keys(ex.contexts).length ? Object.keys(ex.contexts) : ['unprivileged'];
    212         contexts.forEach((ctx) => {
    213           const ctxObj = ex.contexts?.[ctx] || null;
    214           const command = (ctxObj && ctxObj.code) || ex.code;
    215           if (!command || !String(command).trim()) return;
    216 
    217           let capability, nativeCategory, mitre, name;
    218           if (fn === 'inherit') {
    219             const from = String(ex.from || '');
    220             const inheritedFns = funcsOf(from);
    221             const caps = uniq(inheritedFns.flatMap((f) => GTFO_FN_CAP[f] || []));
    222             capability = caps.length ? caps : ['Execution'];
    223             const mit = uniq(inheritedFns.flatMap((f) => GTFO_FN_MITRE[f] || []));
    224             mitre = uniq([...(ex.mitre || []), ...(mit.length ? mit : ['T1059'])]);
    225             nativeCategory = uniq(['inherit', ...inheritedFns.map((f) => `from:${from}`)]);
    226             name = `inherit ← ${from}`;
    227           } else {
    228             capability = [...(GTFO_FN_CAP[fn] || ['Execution'])];
    229             nativeCategory = [fn];
    230             mitre = uniq([...(ex.mitre || []), ...(GTFO_FN_MITRE[fn] || [])]);
    231             name = fn;
    232           }
    233           // sudo/suid/capabilities contexts also grant Privilege Escalation.
    234           if ((ctx === 'sudo' || ctx === 'suid' || ctx === 'capabilities') && !capability.includes('Privilege Escalation')) {
    235             capability = [...capability, 'Privilege Escalation'];
    236           }
    237 
    238           techniques.push({
    239             id: `gtfo:${bin}:${fn}:${i}:${ctx}`,
    240             toolId, toolName: bin, name,
    241             source: 'GTFOBins', platform,
    242             capability, nativeCategory,
    243             command: String(command),
    244             description: (ctxObj && ctxObj.comment) || ex.comment || undefined,
    245             mitre,
    246             privilege: ctx,
    247             context: ctx,
    248             references: [pageUrl],
    249           });
    250           tools.get(toolId).count++;
    251         });
    252       });
    253     }
    254   }
    255 
    256   // Attach aliases to their canonical tool.
    257   for (const { from, to } of aliasEdges) {
    258     const t = tools.get(`gtfo:${to}`);
    259     if (t) t.aliases.push(from);
    260   }
    261   return { techniques, tools: [...tools.values()], aliasCount: aliasEdges.length };
    262 }
    263 
    264 // ---------------------------------------------------------------------------
    265 // LOLBAS
    266 // ---------------------------------------------------------------------------
    267 function ingestLolbas() {
    268   const base = join(VENDOR, 'lolbas', 'yml');
    269   const dirs = ['OSBinaries', 'OSLibraries', 'OSScripts', 'OtherMSBinaries']; // HonorableMentions skipped
    270   const techniques = [];
    271   const tools = [];
    272 
    273   for (const d of dirs) {
    274     const dir = join(base, d);
    275     for (const f of listFiles(dir).filter((x) => x.endsWith('.yml'))) {
    276       const data = yaml.load(readFileSync(join(dir, f), 'utf8')) || {};
    277       const name = data.Name || f.replace(/\.yml$/, '');
    278       const stem = name.replace(/\.(exe|dll|ps1|com|scr|bat|cmd|vbs|js|msc|cpl|inf)$/i, '');
    279       // Slug the FULL name (extension included) so exe/vbs twins such as
    280       // SyncAppvPublishingServer.{exe,vbs} stay distinct tools/ids.
    281       const nameSlug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
    282       const toolId = `lolbas:${nameSlug}`;
    283       const pageUrl = `https://lolbas-project.github.io/lolbas/${lolbasUrlType[d]}/${stem}/`;
    284       const fullPath = asArray(data.Full_Path).map((p) => p.Path).filter(Boolean);
    285       const aliases = asArray(data.Aliases).map((a) => a.Alias).filter(Boolean);
    286       const contributors = asArray(data.Acknowledgement)
    287         .map((a) => [a.Person, a.Handle].filter(Boolean).join(' '))
    288         .filter((s) => s && s.toLowerCase() !== 'unknown');
    289 
    290       tools.push({
    291         id: toolId, name, source: 'LOLBAS', platform: ['Windows'], toolType: lolbasType[d],
    292         aliases, fullPath, author: data.Author || undefined, created: data.Created || undefined,
    293         contributors, references: [pageUrl], count: 0,
    294       });
    295       const tool = tools[tools.length - 1];
    296 
    297       asArray(data.Commands).forEach((c, i) => {
    298         const cat = c.Category;
    299         const capability = LOLBAS_CAT_CAP[cat] ? [...LOLBAS_CAT_CAP[cat]] : ['Execution'];
    300         const detection = asArray(data.Detection).map((det) => {
    301           const key = ['IOC', 'Sigma', 'Analysis', 'Elastic', 'Splunk', 'BlockRule'].find((k) => det[k]);
    302           return key ? { type: key, value: String(det[key]) } : null;
    303         }).filter(Boolean);
    304         const references = uniq([...asArray(data.Resources).map((r) => r.Link), pageUrl]);
    305 
    306         techniques.push({
    307           id: `${toolId}:${i}`,
    308           toolId, toolName: name, name: cat,
    309           source: 'LOLBAS', platform: ['Windows'],
    310           capability, nativeCategory: [cat],
    311           command: String(c.Command || '').trim(),
    312           description: c.Description || undefined,
    313           usecase: c.Usecase || undefined,
    314           mitre: c.MitreID ? [String(c.MitreID)] : [],
    315           privilege: normPrivilege(c.Privileges),
    316           fullPath, toolType: lolbasType[d],
    317           detection: detection.length ? detection : undefined,
    318           references,
    319         });
    320         tool.count++;
    321       });
    322     }
    323   }
    324   return { techniques, tools };
    325 }
    326 
    327 // ---------------------------------------------------------------------------
    328 // WADComs — shared normalizer for upstream (.md) and daemon-authored (TS/JSON).
    329 // ---------------------------------------------------------------------------
    330 function wadPlatform(os, services, items) {
    331   const plats = new Set();
    332   for (const o of asArray(os)) {
    333     const s = String(o).toLowerCase();
    334     if (s.includes('windows')) plats.add('Windows');
    335     else if (s.includes('linux') || s.includes('unix')) plats.add('Linux');
    336     else if (s.includes('mac') || s.includes('osx') || s.includes('darwin')) plats.add('macOS');
    337   }
    338   const isAD = asArray(services).some((s) => AD_SERVICES.has(s)) || asArray(items).some((i) => AD_ITEMS.has(i));
    339   if (isAD) { plats.add('ActiveDirectory'); plats.add('Windows'); }
    340   if (plats.size === 0) plats.add('Windows');
    341   return [...plats];
    342 }
    343 
    344 function wadTechnique(entry, source) {
    345   const attackTypes = asArray(entry.attackTypes ?? entry.attack_types);
    346   const os = asArray(entry.os ?? entry.OS);
    347   const services = asArray(entry.services);
    348   const items = asArray(entry.items);
    349   const references = asArray(entry.references);
    350   const capability = uniq(attackTypes.flatMap((a) => WAD_ATTACK_CAP[a] || []));
    351   const slug = entry.slug;
    352   const family = String(slug).split('-')[0] || slug;
    353   // Impacket is a *suite* of independent example scripts, not one tool — file
    354   // each technique under its own `examples/<script>.py` so the ~48 Impacket
    355   // techniques split into per-script pages instead of collapsing onto one.
    356   let toolId = `wadcoms:${family}`;
    357   let toolName = family;
    358   if (family === 'Impacket') {
    359     const script = impacketScript(references, entry.command);
    360     if (script) { toolId = `wadcoms:Impacket-${script}`; toolName = `Impacket-${script}`; }
    361   }
    362   return {
    363     id: `wadcoms:${slug}`,
    364     toolId,
    365     toolName,
    366     name: entry.name || slug,
    367     source,
    368     platform: wadPlatform(os, services, items),
    369     capability,
    370     nativeCategory: attackTypes.slice(),
    371     command: String(entry.command || '').trim(),
    372     description: entry.description || undefined,
    373     mitre: parseMitreFromRefs(references),
    374     requires: items.length ? items : undefined,
    375     services: services.length ? services : undefined,
    376     references,
    377     added: source === 'DAEMON' ? true : undefined,
    378   };
    379 }
    380 
    381 function ingestWadcomsUpstream() {
    382   const dir = join(VENDOR, 'wadcoms', '_wadcoms');
    383   const techniques = [];
    384   for (const f of listFiles(dir).filter((x) => x.endsWith('.md'))) {
    385     const fm = parseFrontMatter(readFileSync(join(dir, f), 'utf8'));
    386     const slug = f.replace(/\.md$/, '');
    387     const t = wadTechnique({ ...fm, slug }, 'WADComs');
    388     if (t.command) techniques.push(t);
    389   }
    390   return techniques;
    391 }
    392 
    393 async function ingestWadcomsAdditions() {
    394   const entries = JSON.parse(readFileSync(WADCOMS_JSON, 'utf8'));
    395   if (!entries.length) throw new Error('Authored WADComs source is empty');
    396   return entries;
    397 }
    398 
    399 // ---------------------------------------------------------------------------
    400 // DÆMON modernization backlog (§7) — optional, from the workflow output.
    401 // ---------------------------------------------------------------------------
    402 function ingestBacklog() {
    403   if (!existsSync(BACKLOG_JSON)) {
    404     console.warn('  ! DÆMON backlog not found (src/data/daemon-backlog.json) — skipping §7 additions');
    405     return { techniques: [], tools: [] };
    406   }
    407   const raw = JSON.parse(readFileSync(BACKLOG_JSON, 'utf8'));
    408   const list = Array.isArray(raw) ? raw : raw.entries || raw.keep || [];
    409   const techniques = [];
    410   const toolMap = new Map();
    411   list.forEach((e, i) => {
    412     const toolId = `daemon:${e.toolId || String(e.toolName).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
    413     const platform = (e.platform || []).filter((p) => PLATFORMS.includes(p));
    414     const capability = uniq((e.capability || []).filter((c) => CAPABILITIES.includes(c)));
    415     techniques.push({
    416       id: `daemon:${e.toolId || i}:${i}`,
    417       toolId, toolName: e.toolName,
    418       name: (e.nativeCategory && e.nativeCategory[0]) || undefined,
    419       source: 'DAEMON',
    420       platform: platform.length ? platform : ['Windows'],
    421       capability: capability.length ? capability : ['Execution'],
    422       nativeCategory: e.nativeCategory || [],
    423       command: String(e.command || '').trim(),
    424       description: e.description || undefined,
    425       usecase: e.usecase || undefined,
    426       mitre: uniq(e.mitre || []),
    427       privilege: e.privilege || undefined,
    428       detection: e.detection
    429         ? (Array.isArray(e.detection) ? e.detection : [{ type: 'Detection', value: String(e.detection) }])
    430         : undefined,
    431       references: asArray(e.references),
    432       added: true,
    433       verifyNote: e.verifyNote || undefined,
    434     });
    435     if (!toolMap.has(toolId)) {
    436       toolMap.set(toolId, { id: toolId, name: e.toolName, source: 'DAEMON', platform: platform.length ? platform : ['Windows'], references: asArray(e.references), count: 0 });
    437     }
    438     toolMap.get(toolId).count++;
    439   });
    440   return { techniques, tools: [...toolMap.values()] };
    441 }
    442 
    443 // Build per-tool records for the WADComs (upstream + daemon) techniques, which
    444 // carry no separate tool file.
    445 function toolsFromWad(techniques) {
    446   const map = new Map();
    447   for (const t of techniques) {
    448     if (!map.has(t.toolId)) {
    449       map.set(t.toolId, { id: t.toolId, name: t.toolName, source: t.source, platform: [], references: [], count: 0 });
    450     }
    451     const tool = map.get(t.toolId);
    452     tool.count++;
    453     tool.platform = uniq([...tool.platform, ...t.platform]);
    454     tool.references = uniq([...tool.references, ...t.references]);
    455     // A wadcoms tool family may hold both upstream and daemon entries; if any
    456     // is daemon-authored keep the source honest as mixed → prefer WADComs base.
    457     if (t.source === 'DAEMON' && tool.source !== 'DAEMON') tool.source = 'WADComs';
    458   }
    459   return [...map.values()];
    460 }
    461 
    462 // ---------------------------------------------------------------------------
    463 // Main
    464 // ---------------------------------------------------------------------------
    465 async function main() {
    466   console.log('DÆMONBins dataset build\n');
    467   for (const dir of ['gtfobins/_gtfobins', 'lolbas/yml', 'wadcoms/_wadcoms']) {
    468     if (!existsSync(join(VENDOR, dir))) throw new Error(`Missing upstream source: vendor/${dir}`);
    469   }
    470   const oldRows = JSON.parse(readFileSync(join(OUT_SRC, 'techniques.json'), 'utf8'));
    471 
    472   const gtfo = ingestGtfobins();
    473   console.log(`  GTFOBins : ${gtfo.techniques.length} techniques, ${gtfo.tools.length} tools (${gtfo.aliasCount} aliases)`);
    474 
    475   const lolbas = ingestLolbas();
    476   console.log(`  LOLBAS   : ${lolbas.techniques.length} techniques, ${lolbas.tools.length} tools`);
    477 
    478   const wadUp = ingestWadcomsUpstream();
    479   console.log(`  WADComs  : ${wadUp.length} techniques (upstream)`);
    480 
    481   const wadAdd = await ingestWadcomsAdditions();
    482   console.log(`  WADComs+ : ${wadAdd.length} techniques (daemon additions)`);
    483 
    484   const backlog = ingestBacklog();
    485   console.log(`  DÆMON §7 : ${backlog.techniques.length} techniques (modernization backlog)`);
    486 
    487   // Fold case-duplicate WADComs families (e.g. Enum4Linux / enum4linux) onto
    488   // one canonical page so the static router never silently drops a tool.
    489   const wadTechs = canonicalizeFamilyCase([...wadUp, ...wadAdd]);
    490   const techniques = [
    491     ...gtfo.techniques, ...lolbas.techniques, ...wadTechs, ...backlog.techniques,
    492   ];
    493   // Retain all established anchors. New records use content-derived IDs rather
    494   // than upstream positions, so reordering cannot reassign a bookmarked ID.
    495   const identity = t => JSON.stringify([t.toolId, t.command, t.context || t.privilege || '', t.nativeCategory]);
    496   const oldIds = new Map();
    497   for (const t of oldRows) {
    498     const key = identity(t), ids = oldIds.get(key) || [];
    499     ids.push(t.id); oldIds.set(key, ids);
    500   }
    501   for (const t of techniques) t.id = oldIds.get(identity(t))?.shift() || stableId(t.toolId, identity(t));
    502   const wadTools = toolsFromWad(wadTechs);
    503   const tools = [...gtfo.tools, ...lolbas.tools, ...wadTools, ...backlog.tools];
    504 
    505   // Validate every record (schema + unique ids); fail the build on any bad one.
    506   const problems = validateTechniques(techniques);
    507   if (problems.length) {
    508     for (const p of problems.slice(0, 10)) console.error(`  ✗ ${p}`);
    509     console.error(`\nBUILD FAILED: ${problems.length} invalid/duplicate technique record(s).`);
    510     process.exit(1);
    511   }
    512 
    513   // Derived facet indexes + counts.
    514   const count = (arr, key) => arr.reduce((m, v) => ((m[v] = (m[v] || 0) + 1), m), {});
    515   const bySource = count(techniques.map((t) => t.source));
    516   const byPlatform = {};
    517   const byCapability = {};
    518   for (const t of techniques) {
    519     for (const p of t.platform) byPlatform[p] = (byPlatform[p] || 0) + 1;
    520     for (const c of t.capability) byCapability[c] = (byCapability[c] || 0) + 1;
    521   }
    522   const facets = {
    523     platforms: PLATFORMS.filter((p) => byPlatform[p]),
    524     capabilities: CAPABILITIES.filter((c) => byCapability[c]),
    525     sources: SOURCES.filter((s) => bySource[s]),
    526     counts: {
    527       techniques: techniques.length,
    528       tools: tools.length,
    529       added: techniques.filter((t) => t.added).length,
    530       bySource, byPlatform, byCapability,
    531       toolsBySource: count(tools.map((t) => t.source)),
    532     },
    533     commits: {
    534       gtfobins: readCommit('gtfobins'), lolbas: readCommit('lolbas'), wadcoms: readCommit('wadcoms'),
    535     },
    536   };
    537 
    538   // Emit.
    539   mkdirSync(OUT_SRC, { recursive: true });
    540   mkdirSync(OUT_PUB, { recursive: true });
    541   const j = (o) => JSON.stringify(o, null, 0);
    542   writeFileSync(join(OUT_SRC, 'techniques.json'), JSON.stringify(techniques));
    543   writeFileSync(join(OUT_SRC, 'tools.json'), JSON.stringify(tools));
    544   writeFileSync(join(OUT_SRC, 'facets.json'), JSON.stringify(facets, null, 2));
    545   writeFileSync(join(OUT_PUB, 'techniques.json'), j(techniques));
    546 
    547   console.log(`\n  TOTAL    : ${techniques.length} techniques, ${tools.length} tools`);
    548   console.log(`  by source: ${JSON.stringify(bySource)}`);
    549   console.log(`  by platform: ${JSON.stringify(byPlatform)}`);
    550   console.log(`  added(NEW): ${facets.counts.added}`);
    551   console.log('\n  wrote src/data/{techniques,tools,facets}.json + public/data/techniques.json');
    552   enrich();
    553 }
    554 
    555 function readCommit(name) {
    556   try {
    557     const head = readFileSync(join(VENDOR, name, '.git', 'HEAD'), 'utf8').trim();
    558     if (head.startsWith('ref:')) {
    559       const ref = head.slice(4).trim();
    560       return readFileSync(join(VENDOR, name, '.git', ref), 'utf8').trim().slice(0, 7);
    561     }
    562     return head.slice(0, 7);
    563   } catch { return null; }
    564 }
    565 
    566 main().catch((e) => { console.error(e); process.exit(1); });