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

catalog.ts (14771B)


      1 import { filtersFromSearch, filtersToSearch, filterTechniques, countActive, EMPTY_FILTERS, FACET_KEYS, toolRoute, type Technique, type CatalogFilters, type FacetKey } from '../lib/techniques';
      2 import { PAGE, renderRow, countLabel, EMPTY_HTML } from '../lib/render-row';
      3 import { sortTechniques, groupTechniques, contextualCounts, configureCommand, type SortOrder } from '../lib/catalog-workspace';
      4 import { escapeHtml as esc, highlightCommand } from '../lib/highlight';
      5 import { url } from '../lib/url';
      6 import './copy';
      7 
      8 function readStore<T>(key: string, fallback: T): T { try { return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback; } catch { return fallback; } }
      9 
     10 function init(root: HTMLElement, all: Technique[]) {
     11   const $ = <T extends HTMLElement = HTMLElement>(s: string) => root.querySelector<T>(s)!;
     12   const list = $('[data-cat-results]'), rail = $('[data-cat-facets]'), panel = $('#catalog-inspector');
     13   const search = $<HTMLInputElement>('[data-cat-search]'), sort = $<HTMLSelectElement>('[data-sort]'), group = $<HTMLInputElement>('[data-group]');
     14   const status = $('[data-workspace-status]');
     15   const byId = new Map(all.map(t => [t.id, t]));
     16   let filters: CatalogFilters = filtersFromSearch(location.search);
     17   const storedBookmarks = readStore<unknown>('daemonbins:saved', []);
     18   const bookmarks = new Set<string>(Array.isArray(storedBookmarks) ? storedBookmarks.filter(id => typeof id === 'string' && byId.has(id)) : []);
     19   const storedViews = readStore<unknown>('daemonbins:views', []);
     20   let views: { name: string; query: string }[] = Array.isArray(storedViews) ? storedViews.filter(v => v && typeof v.name === 'string' && typeof v.query === 'string').slice(0, 20) : [];
     21   let savedOnly = false, selected = '', limit = PAGE;
     22   let groups: Technique[][] = [];
     23   let opener: HTMLElement | null = null;
     24   let debounce: ReturnType<typeof setTimeout>;
     25   const save = (key: string, value: unknown) => { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { status.textContent = 'Browser storage is unavailable. Changes last for this visit only.'; return false; } };
     26   function readURL() {
     27     filters = filtersFromSearch(location.search);
     28     const p = new URLSearchParams(location.search);
     29     sort.value = ['tool', 'source', 'reviewed'].includes(p.get('sort') || '') ? p.get('sort')! : 'tool';
     30     group.checked = p.get('group') !== '0'; savedOnly = p.get('saved') === '1'; selected = p.get('id') || '';
     31   }
     32   function query() {
     33     const p = new URLSearchParams(filtersToSearch(filters));
     34     if (sort.value !== 'tool') p.set('sort', sort.value);
     35     if (!group.checked) p.set('group', '0');
     36     if (savedOnly) p.set('saved', '1');
     37     if (selected) p.set('id', selected);
     38     return p.toString();
     39   }
     40   function sync(push: boolean) {
     41     const next = location.pathname + (query() ? `?${query()}` : '');
     42     if (next !== location.pathname + location.search) history[push ? 'pushState' : 'replaceState']({}, '', next);
     43   }
     44   function syncBookmarks() {
     45     $('[data-bookmark-count]').textContent = String(bookmarks.size);
     46     root.querySelectorAll<HTMLButtonElement>('[data-save]').forEach(b => {
     47       const id = b.closest<HTMLElement>('[data-id]')?.dataset.id;
     48       const active = !!id && bookmarks.has(id); b.setAttribute('aria-pressed', String(active)); b.textContent = active ? 'Saved' : 'Save';
     49     });
     50   }
     51   function updateCounts() {
     52     const base = savedOnly ? all.filter(t => bookmarks.has(t.id)) : all;
     53     const counts = contextualCounts(base, filters);
     54     rail.querySelectorAll<HTMLButtonElement>('[data-facet]').forEach(b => {
     55       const k = b.dataset.facet as FacetKey, value = b.dataset.value!;
     56       const active = filters[k]?.includes(value) || false;
     57       b.setAttribute('aria-checked', String(active));
     58       b.querySelector('.cat-chip__n')!.textContent = String(counts[k][value] || 0);
     59       b.disabled = !active && !counts[k][value];
     60     });
     61   }
     62   function render(append = false) {
     63     const matching = sortTechniques(filterTechniques(savedOnly ? all.filter(t => bookmarks.has(t.id)) : all, filters), sort.value as SortOrder, filters.query);
     64     groups = group.checked ? groupTechniques(matching) : matching.map(t => [t]);
     65     const start = append ? Math.max(0, limit - PAGE) : 0;
     66     const html = groups.slice(start, limit).map(g => renderRow(g[0], g)).join('');
     67     if (append) list.insertAdjacentHTML('beforeend', html); else list.innerHTML = html || EMPTY_HTML;
     68     $('[data-cat-count]').innerHTML = countLabel(matching.length, all.length, countActive(filters), filters.addedOnly) + (group.checked ? ` · ${groups.length} tool${groups.length === 1 ? '' : 's'}` : '');
     69     const more = $<HTMLButtonElement>('[data-cat-more]'); more.hidden = groups.length <= limit;
     70     more.textContent = `Show ${Math.min(PAGE, Math.max(0, groups.length - limit))} more ${group.checked ? 'tools' : 'techniques'}`;
     71     search.value = filters.query;
     72     $('[data-cat-new]').setAttribute('aria-pressed', String(filters.addedOnly));
     73     $('[data-bookmarks-only]').setAttribute('aria-pressed', String(savedOnly));
     74     $('[data-active-filters]').innerHTML = FACET_KEYS.flatMap(k => (filters[k] || []).map(v => `<button type="button" data-remove="${k}" data-value="${esc(v)}" aria-label="Remove ${esc(k)} filter ${esc(v)}">${esc(v)} ×</button>`)).join('');
     75     syncBookmarks(); updateCounts();
     76   }
     77   const paragraph = (title: string, value?: string) => value ? `<h3>${esc(title)}</h3><p>${esc(value)}</p>` : '';
     78   function inspect(id: string, focus = false) {
     79     const t = byId.get(id); selected = t ? id : '';
     80     if (!t) { panel.hidden = true; return; }
     81     panel.hidden = false; panel.dataset.id = id;
     82     panel.innerHTML = `<div class="inspector__top"><span class="eyebrow">${esc(t.source)}</span><button data-close-inspector type="button" aria-label="Close technique details">Close ×</button></div>
     83       <h2 tabindex="-1">${esc(t.toolName)}</h2><p>${esc(t.name || t.capability.join(', '))}</p>
     84       <p>${esc(t.verification || 'Upstream reference')}${t.reviewedAt ? ` · reviewed ${esc(t.reviewedAt)}` : ''}</p>
     85       ${paragraph('About this command', t.description)}
     86       ${t.template ? `<h3>Configure command</h3><p>${t.template.shell === 'posix' ? 'POSIX shell' : 'PowerShell'} quoting. Values stay in this panel and are not saved or added to the URL.</p><form class="command-fields">${t.template.variables.map(v => `<label>${esc(v.label)}<input data-variable="${esc(v.key)}" value="${esc(v.default)}" autocomplete="off" spellcheck="false" /></label>`).join('')}</form><p class="template-error" role="status"></p>` : ''}
     87       <div data-cmdbar><pre><code data-preview>${highlightCommand(t.command)}</code></pre><button data-copy type="button">Copy command</button> <button data-save type="button">Save</button></div>
     88       ${paragraph('Required access', t.requires?.join(', '))}${paragraph('Execution context', t.context || t.privilege)}
     89       ${paragraph('Environment', t.environment?.join(', '))}${paragraph('Services', t.services?.join(', '))}
     90       ${paragraph('Compatibility', t.compatibility)}${paragraph('Expected result', t.expected)}${paragraph('Troubleshooting', t.troubleshooting)}${paragraph('Side effects', t.sideEffects)}${paragraph('Restore', t.restore)}
     91       ${t.mitre.length ? `<h3>ATT&CK</h3><p>${t.mitre.map(m => `<a href="https://attack.mitre.org/techniques/${esc(m.replace('.', '/'))}/" target="_blank" rel="noopener">${esc(m)}</a>`).join(' · ')}</p>` : ''}
     92       ${t.detection?.length ? `<h3>Detection references</h3><ul>${t.detection.map(d => `<li>${esc(d.type)}: ${/^https?:\/\//.test(d.value) ? `<a href="${esc(d.value)}" target="_blank" rel="noopener">Source</a>` : esc(d.value)}</li>`).join('')}</ul>` : ''}
     93       <h3>Sources</h3><ul>${t.references.map((r, i) => `<li><a href="${esc(r)}" target="_blank" rel="noopener">${esc(new URL(r).hostname)} · reference ${i + 1}</a></li>`).join('')}</ul><p><a href="${url(toolRoute(t.toolId))}#${esc(t.id)}">Open full tool reference →</a></p>`;
     94     panel.querySelector('form')?.addEventListener('submit', e => e.preventDefault());
     95     panel.querySelector('form')?.addEventListener('input', () => {
     96       try {
     97         const values = Object.fromEntries([...panel.querySelectorAll<HTMLInputElement>('[data-variable]')].map(el => [el.dataset.variable!, el.value]));
     98         panel.querySelector('[data-preview]')!.innerHTML = highlightCommand(configureCommand(t.template!, values));
     99         panel.querySelector('.template-error')!.textContent = '';
    100         panel.querySelector<HTMLButtonElement>('[data-copy]')!.disabled = false;
    101       } catch (e) {
    102         panel.querySelector('.template-error')!.textContent = (e as Error).message;
    103         panel.querySelector<HTMLButtonElement>('[data-copy]')!.disabled = true;
    104       }
    105     });
    106     syncBookmarks();
    107     if (focus) { panel.querySelector<HTMLElement>('h2')!.focus(); if (matchMedia('(max-width:1200px)').matches) panel.scrollIntoView({ block: 'start' }); }
    108   }
    109   function apply(push = true) { clearTimeout(debounce); limit = PAGE; sync(push); render(); }
    110   root.addEventListener('click', e => {
    111     const b = (e.target as HTMLElement).closest<HTMLElement>('button'); if (!b) return;
    112     if (b.hasAttribute('data-facet') || b.hasAttribute('data-remove')) {
    113       const k = (b.dataset.facet || b.dataset.remove) as FacetKey, value = b.dataset.value!;
    114       const values = new Set(filters[k] || []); values.has(value) ? values.delete(value) : values.add(value); filters = { ...filters, [k]: [...values] }; apply();
    115     } else if (b.hasAttribute('data-cat-clear')) { filters = { ...EMPTY_FILTERS }; savedOnly = false; selected = ''; inspect(''); apply(); }
    116     else if (b.hasAttribute('data-cat-new')) { filters = { ...filters, addedOnly: !filters.addedOnly }; apply(); }
    117     else if (b.hasAttribute('data-bookmarks-only')) { savedOnly = !savedOnly; apply(); }
    118     else if (b.hasAttribute('data-cat-more')) { limit += PAGE; render(true); }
    119     else if (b.hasAttribute('data-filters-toggle')) { const open = root.dataset.filtersOpen !== 'true'; root.dataset.filtersOpen = String(open); b.setAttribute('aria-expanded', String(open)); }
    120     else if (b.hasAttribute('data-inspect')) { opener = b; inspect(b.closest<HTMLElement>('[data-id]')!.dataset.id!, true); sync(true); }
    121     else if (b.hasAttribute('data-close-inspector')) { inspect(''); sync(true); if (opener?.isConnected) opener.focus(); else search.focus(); }
    122     else if (b.hasAttribute('data-save')) {
    123       const id = b.closest<HTMLElement>('[data-id]')!.dataset.id!; bookmarks.has(id) ? bookmarks.delete(id) : bookmarks.add(id);
    124       save('daemonbins:saved', [...bookmarks]); if (savedOnly) render(); else syncBookmarks();
    125     }
    126   });
    127   list.addEventListener('change', e => {
    128     const el = e.target as HTMLSelectElement; if (!el.matches('[data-variant]')) return;
    129     const t = byId.get(el.value)!; const variants = groups.find(g => g[0].toolId === t.toolId)!;
    130     const row = el.closest('.trow')!; row.outerHTML = renderRow(t, variants); syncBookmarks();
    131     [...list.querySelectorAll<HTMLElement>('[data-id]')].find(r => r.dataset.id === t.id)?.querySelector<HTMLSelectElement>('select')?.focus();
    132     if (selected && byId.get(selected)?.toolId === t.toolId) { inspect(t.id); sync(false); }
    133   });
    134   search.addEventListener('input', () => { clearTimeout(debounce); debounce = setTimeout(() => { filters = { ...filters, query: search.value }; apply(false); }, 130); });
    135   sort.addEventListener('change', () => apply()); group.addEventListener('change', () => apply());
    136   function viewsMenu() {
    137     $<HTMLSelectElement>('[data-views]').innerHTML = '<option value="">Choose a saved view</option>' + views.map((v, i) => `<option value="${i}">${esc(v.name)}</option>`).join('');
    138     $('[data-delete-view]').hidden = true;
    139   }
    140   $('[data-save-view]').addEventListener('click', () => {
    141     const name = window.prompt('Name this view', filters.query || 'My catalog view'); if (!name?.trim()) return;
    142     const p = new URLSearchParams(query()); p.delete('id');
    143     views = [...views.filter(v => v.name !== name.trim()), { name: name.trim().slice(0, 80), query: p.toString() }].slice(-20);
    144     const persisted = save('daemonbins:views', views); viewsMenu(); if (persisted) status.textContent = 'View saved in this browser.';
    145   });
    146   $('[data-views]').addEventListener('change', e => {
    147     const val = (e.target as HTMLSelectElement).value; $('[data-delete-view]').hidden = val === ''; if (val === '') return;
    148     history.pushState({}, '', location.pathname + '?' + views[Number(val)].query); readURL(); limit = PAGE; render(); inspect(selected);
    149   });
    150   $('[data-delete-view]').addEventListener('click', () => {
    151     const index = $<HTMLSelectElement>('[data-views]').value; if (index === '') return;
    152     views.splice(Number(index), 1); save('daemonbins:views', views); viewsMenu();
    153   });
    154   const prefs = readStore<Record<string, unknown>>('daemonbins:display', {});
    155   function display() {
    156     root.dataset.density = $<HTMLSelectElement>('[data-density]').value;
    157     root.dataset.wrap = String($<HTMLInputElement>('[data-wrap]').checked);
    158     root.style.setProperty('--command-size', $<HTMLSelectElement>('[data-font]').value + 'px');
    159     document.documentElement.dataset.motion = $<HTMLInputElement>('[data-motion]').checked ? 'reduce' : 'system';
    160   }
    161   for (const name of ['density', 'font', 'wrap', 'motion']) {
    162     const el = $<HTMLInputElement | HTMLSelectElement>(`[data-${name}]`);
    163     if (el instanceof HTMLInputElement) el.checked = prefs[name] === true;
    164     else if ([...el.options].some(o => o.value === prefs[name])) el.value = String(prefs[name]);
    165     el.addEventListener('change', () => { prefs[name] = el instanceof HTMLInputElement ? el.checked : el.value; save('daemonbins:display', prefs); display(); });
    166   }
    167   const onPop = () => { clearTimeout(debounce); readURL(); limit = PAGE; render(); inspect(selected); };
    168   window.addEventListener('popstate', onPop);
    169   document.addEventListener('astro:before-swap', () => { clearTimeout(debounce); window.removeEventListener('popstate', onPop); }, { once: true });
    170   readURL(); display(); viewsMenu(); render(); inspect(selected);
    171 }
    172 
    173 async function boot() {
    174   const root = document.querySelector<HTMLElement>('[data-catalog]'); if (!root || root.dataset.booted) return;
    175   root.dataset.booted = '1';
    176   try {
    177     const res = await fetch(root.dataset.indexUrl!); if (!res.ok) throw new Error(String(res.status));
    178     const data = await res.json(); if (root.isConnected) init(root, data);
    179   } catch (e) {
    180     root.querySelector('[data-workspace-status]')!.textContent = 'Interactive catalog could not load. Reload to retry, or follow a tool link for its static reference.';
    181     console.error('[catalog]', e);
    182   }
    183 }
    184 boot(); document.addEventListener('astro:page-load', boot);