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

render-row.ts (8491B)


      1 // Pure renderers shared by the catalog island (client) and catalog.astro
      2 // (server-rendered first page): chips, badges, one technique row. No DOM, no
      3 // side effects — the same function builds the SSR'd rows and the fetched ones,
      4 // which is what lets the island adopt the server's markup without a re-render.
      5 
      6 import { toolRoute, type Technique } from './techniques';
      7 import { PLATFORM_META, CAPABILITY_META, SOURCE_META } from './taxonomy';
      8 import { escapeHtml as esc, highlightCommand } from './highlight';
      9 import { url } from './url';
     10 import { FACET_KEYS, facetValues, searchText, type CatalogFilters } from './techniques';
     11 
     12 /** Rows per page, server and client alike. */
     13 export const PAGE = 40;
     14 
     15 /**
     16  * The fields the list view needs — exactly what public/data/index-<hash>.json
     17  * carries (scripts/build-index.mjs keeps an identical list; a test pins them
     18  * together). Everything the text query scans is here, so search behaves the
     19  * same as over the full record; detection, references, paths and the rest
     20  * live on the tool page the row links to.
     21  */
     22 export const LIST_FIELDS = [
     23   'id', 'toolId', 'toolName', 'name', 'source', 'platform', 'capability', 'nativeCategory',
     24   'command', 'description', 'usecase', 'mitre', 'privilege', 'added',
     25   'context', 'requires', 'services', 'environment', 'aliases', 'availability', 'verification',
     26   'reviewedAt', 'compatibility', 'expected', 'troubleshooting', 'sideEffects', 'restore', 'template', 'references', 'detection',
     27 ] as const;
     28 export type ListTechnique = Pick<Technique, (typeof LIST_FIELDS)[number]>;
     29 
     30 export function toListTechnique(t: Technique): ListTechnique {
     31   const out: Partial<Record<(typeof LIST_FIELDS)[number], unknown>> = {};
     32   for (const k of LIST_FIELDS) if (t[k] !== undefined) out[k] = t[k];
     33   return out as ListTechnique;
     34 }
     35 
     36 // ---- chips -----------------------------------------------------------------
     37 export function chip(kind: string, value: string, label: string, accent: string, active: boolean, count: number): string {
     38   return `<button type="button" class="cat-chip" role="checkbox" aria-checked="${active}" data-facet="${kind}" data-value="${esc(value)}" style="--acc: var(--${accent});">
     39     <span class="cat-chip__lbl">${esc(label)}</span><span class="cat-chip__n">${count}</span></button>`;
     40 }
     41 
     42 export function facetGroup(title: string, kind: string, values: readonly string[], meta: (v: string) => string, active: string[], counts: Record<string, number>): string {
     43   const chips = values.filter((v) => counts[v]).map((v) => chip(kind, v, v, meta(v), active.includes(v), counts[v] || 0)).join('');
     44   return `<section class="cat-facet"><p class="cat-facet__head eyebrow"><span class="eyebrow__mark">^:</span><span>${esc(title)}</span></p><div class="cat-facet__chips">${chips}</div></section>`;
     45 }
     46 
     47 export type FacetCounts = Partial<Record<(typeof FACET_KEYS)[number], Record<string, number>>>;
     48 export function totalFacetCounts(rows: Technique[]): FacetCounts {
     49   return Object.fromEntries(FACET_KEYS.map(k => {
     50     const counts: Record<string, number> = {};
     51     for (const t of rows) for (const v of facetValues(t, k)) counts[v] = (counts[v] || 0) + 1;
     52     return [k, counts];
     53   }));
     54 }
     55 
     56 /** The whole facet rail: three groups, chip counts are dataset totals. */
     57 export function renderFacets(active: Pick<CatalogFilters, 'platform' | 'capability' | 'source'> & Partial<CatalogFilters>, counts: FacetCounts): string {
     58   const titles = { platform: 'Operating system / target', capability: 'Capability', source: 'Collection', context: 'Execution context', requires: 'Required access', services: 'Service', environment: 'Environment', availability: 'Availability', verification: 'Evidence' };
     59   return FACET_KEYS.map(k => facetGroup(titles[k], k, Object.keys(counts[k] || {}).sort(), () => k === 'source' ? 'rose' : 'foam', active[k] || [], counts[k] || {})).join('');
     60 }
     61 
     62 // ---- badges ----------------------------------------------------------------
     63 export const badge = (label: string, accent: string, extra = '') =>
     64   `<span class="tbadge ${extra}" style="--acc: var(--${accent});">${esc(label)}</span>`;
     65 
     66 // ---- row -------------------------------------------------------------------
     67 /** A DOM id for the detail panel so the expander can name what it controls. */
     68 export const panelId = (id: string) => `td-${id.replace(/[^A-Za-z0-9_-]+/g, '-')}`;
     69 
     70 export function renderRow(t: ListTechnique, variants: ListTechnique[] = []): string {
     71   const caps = t.capability.map((c) => badge(c, CAPABILITY_META[c]?.accent || 'foam')).join('');
     72   const plats = t.platform.map((p) => badge(p, PLATFORM_META[p]?.accent || 'foam', 'tbadge--soft')).join('');
     73   const src = SOURCE_META[t.source];
     74   const srcBadge = badge(src.label, src.accent, 'tbadge--src');
     75   const newBadge = t.added && t.source !== 'DAEMON' ? `<span class="tbadge tbadge--new" style="--acc: var(--love);">DÆMON</span>` : '';
     76   const label = t.name && t.name !== t.toolName ? `<span class="trow__sub">${esc(t.name)}</span>` : '';
     77   const route = url(toolRoute(t.toolId));
     78   const pid = panelId(t.id);
     79 
     80   const mitre = t.mitre?.length ? `<div class="tdetail__row"><span class="tdetail__k">MITRE</span><span class="tdetail__v">${t.mitre.map((m) => `<a href="https://attack.mitre.org/techniques/${esc(m.replace('.', '/'))}/" target="_blank" rel="noopener" class="tchip-link">${esc(m)}</a>`).join(' ')}</span></div>` : '';
     81   const priv = t.privilege ? `<div class="tdetail__row"><span class="tdetail__k">Context</span><span class="tdetail__v">${esc(t.privilege)}</span></div>` : '';
     82   const desc = t.description ? `<p class="tdetail__desc">${esc(t.description)}</p>` : '';
     83   const use = t.usecase ? `<p class="tdetail__use"><span class="tdetail__k">Use</span> ${esc(t.usecase)}</p>` : '';
     84   const more = `<a class="tdetail__more" href="${route}#${esc(t.id)}">Full details on the ${esc(t.toolName)} page — detection, references, paths →</a>`;
     85 
     86   return `<article class="trow" data-id="${esc(t.id)}">
     87     <div class="trow__head">
     88       <button class="trow__inspect" type="button" data-inspect aria-label="Details for ${esc(t.toolName)}" aria-controls="catalog-inspector">↗</button>
     89       <span class="trow__name"><a href="${route}" class="trow__tool">${esc(t.toolName)}</a>${label}</span>
     90       <span class="trow__badges">${srcBadge}${newBadge}${caps}${plats}</span>
     91     </div>
     92     ${variants.length > 1 ? `<label class="trow__variants">${variants.length} matching variants <select data-variant aria-label="Technique variant for ${esc(t.toolName)}">${variants.map(v => `<option value="${esc(v.id)}" ${v.id === t.id ? 'selected' : ''}>${esc(v.name || v.capability.join(', '))} · ${esc(v.context || v.privilege || 'see prerequisites')}</option>`).join('')}</select></label>` : ''}
     93     <div class="trow__cmdbar" data-cmdbar>
     94       <pre class="trow__cmd"><code>${highlightCommand(t.command)}</code></pre>
     95       <button class="trow__copy" type="button" data-copy aria-label="Copy command">copy</button>
     96     </div>
     97     <div class="trow__actions"><span>${esc(t.context || t.privilege || t.environment?.join(', ') || 'See prerequisites')}</span><button data-save type="button" aria-pressed="false">Save</button><button data-inspect type="button" aria-controls="catalog-inspector">${t.template ? 'Configure & details' : 'Details & sources'}</button></div>
     98     <div class="trow__detail" id="${pid}" hidden>
     99       ${desc}${use}
    100       <div class="tdetail__grid">${priv}${mitre}</div>
    101       ${more}
    102     </div>
    103   </article>`;
    104 }
    105 
    106 export const EMPTY_HTML = `<div class="cat-empty"><p class="eyebrow" style="--acc: var(--love);"><span class="eyebrow__mark">^:</span><span>No matches</span></p><p>Nothing matches this filter. Loosen a facet or clear the search.</p></div>`;
    107 
    108 /** The count line and the "Show more" label, so server and client agree. */
    109 export function countLabel(shown: number, total: number, activeFacets: number, addedOnly: boolean): string {
    110   return `<strong>${shown.toLocaleString()}</strong> of ${total.toLocaleString()} techniques${activeFacets ? ` · ${activeFacets} filter${activeFacets === 1 ? '' : 's'}` : ''}${addedOnly ? ' · DÆMON authored' : ''}`;
    111 }
    112 export function moreLabel(remaining: number): string {
    113   return `Show ${Math.min(remaining, PAGE)} more · ${remaining} remaining`;
    114 }
    115 
    116 /**
    117  * The same fields lib/techniques.ts#matchesQuery scans, lowercased once per
    118  * record at boot instead of once per record per keystroke.
    119  */
    120 export function haystack(t: ListTechnique): string {
    121   return searchText(t as Technique);
    122 }