wadcoms-normalize.mjs (4234B)
1 /** 2 * WADComs family normalisation — shared by the dataset builder 3 * (build-dataset.mjs) and the one-time committed-data migration 4 * (split-impacket.mjs), so a fresh regen and the checked-in JSON agree. 5 * 6 * Two normalisations live here: 7 * 8 * 1. splitImpacket — Impacket is not one tool, it is a *suite* of independent 9 * example scripts (secretsdump.py, ntlmrelayx.py, psexec.py, …). WADComs 10 * files them all under a single "Impacket" family, which collapses ~48 11 * distinct techniques onto one unreadable tool page. We split them back out 12 * by the actual `examples/<script>.py` each technique references, so every 13 * script gets its own tool exactly as impacket ships — `ntlmrelayx.py`'s 14 * ten relay variants group together, `secretsdump.py`'s two do, and so on. 15 * 16 * 2. canonicalizeFamilyCase — two WADComs families that differ only in case 17 * (e.g. `Enum4Linux` and `enum4linux`) slug to the same route, and the 18 * static tool page silently drops one. We fold case-variants of the same 19 * toolId onto a single canonical spelling (the one with more techniques). 20 * 21 * Both are pure and deterministic (no wall-clock, no randomness). 22 */ 23 24 // Match `…/impacket/…/examples/<script>.py`. The script basename is impacket's 25 // own canonical casing (secretsdump, GetUserSPNs, getST, ntlmrelayx, …). 26 const IMPACKET_EXAMPLE_RE = /impacket\/(?:[^\s"']*\/)?examples\/([A-Za-z0-9_+.-]+)\.py/i; 27 const PY_BASENAME_RE = /([A-Za-z0-9_+.-]+)\.py/; 28 29 /** 30 * Resolve the impacket example script a technique belongs to, preferring the 31 * `examples/<script>.py` reference URL and falling back to the first `*.py` 32 * token in the command. Returns null when neither resolves (the technique is 33 * then left under the umbrella Impacket tool rather than mis-filed). 34 */ 35 export function impacketScript(references = [], command = '') { 36 for (const r of references) { 37 const m = IMPACKET_EXAMPLE_RE.exec(String(r)); 38 if (m) return m[1]; 39 } 40 const m = PY_BASENAME_RE.exec(String(command)); 41 return m ? m[1] : null; 42 } 43 44 /** 45 * If a technique is filed under the umbrella `wadcoms:Impacket` family, refile 46 * it under `wadcoms:Impacket-<script>` (toolName `Impacket-<script>`) so it 47 * lands on the script's own page. Idempotent and a no-op for everything else. 48 */ 49 export function splitImpacket(tech) { 50 if (tech.toolId !== 'wadcoms:Impacket') return tech; 51 const script = impacketScript(tech.references, tech.command); 52 if (!script) return tech; 53 return { ...tech, toolId: `wadcoms:Impacket-${script}`, toolName: `Impacket-${script}` }; 54 } 55 56 /** 57 * Fold case-duplicate toolIds within one namespace onto a single canonical 58 * spelling. The canonical variant is the one carrying the most techniques 59 * (tie-break: an uppercase-initial family, then first-seen). Rewrites toolId 60 * and toolName in place on a shallow copy; every other field is untouched. 61 */ 62 export function canonicalizeFamilyCase(techniques) { 63 // key = lowercased toolId → { count, byId: Map<toolId,{count,name,first}> } 64 const groups = new Map(); 65 let order = 0; 66 for (const t of techniques) { 67 const key = t.toolId.toLowerCase(); 68 if (!groups.has(key)) groups.set(key, new Map()); 69 const byId = groups.get(key); 70 if (!byId.has(t.toolId)) byId.set(t.toolId, { count: 0, name: t.toolName, first: order++ }); 71 byId.get(t.toolId).count += 1; 72 } 73 74 const canonical = new Map(); // toolId → { toolId, toolName } 75 for (const byId of groups.values()) { 76 if (byId.size < 2) continue; // no case-variants, nothing to fold 77 const variants = [...byId.entries()].map(([id, v]) => ({ id, ...v })); 78 variants.sort((a, b) => 79 b.count - a.count || 80 (isUpperInitialFamily(b.id) - isUpperInitialFamily(a.id)) || 81 a.first - b.first, 82 ); 83 const win = variants[0]; 84 for (const v of variants) canonical.set(v.id, { toolId: win.id, toolName: win.name }); 85 } 86 87 if (!canonical.size) return techniques; 88 return techniques.map((t) => { 89 const c = canonical.get(t.toolId); 90 return c ? { ...t, toolId: c.toolId, toolName: c.toolName } : t; 91 }); 92 } 93 94 function isUpperInitialFamily(toolId) { 95 const family = toolId.split(':').slice(1).join(':'); 96 return /^[A-Z]/.test(family) ? 1 : 0; 97 }