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); });