techniques.ts (6156B)
1 // Shared Technique model + pure filter/serialize logic. Imported by the Astro 2 // pages at build time and by the client-side catalog island — the functions 3 // have no framework or DOM dependency, so they run in both. 4 5 import { SOURCE_ROUTE, NS_SOURCE } from './taxonomy'; 6 import type { Capability, Platform, SourceId } from './taxonomy'; 7 8 // Route helpers — a tool lives at /<deckRoute>/<toolSlug>. Both the deck and 9 // the slug come from the colon-namespaced toolId ("gtfo:vim" -> gtfobins/vim, 10 // "wadcoms:Certipy" -> wadcoms/certipy), never from a per-technique source, so 11 // a mixed upstream+daemon family always resolves to one page. Lowercased so the 12 // path is case-stable on GitHub Pages. 13 export function toolSlug(toolId: string): string { 14 return toolId.split(':').slice(1).join('-').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); 15 } 16 export function toolNamespace(toolId: string): string { 17 return toolId.split(':')[0]; 18 } 19 export function routeSourceOf(toolId: string): SourceId { 20 return NS_SOURCE[toolNamespace(toolId)] ?? 'DAEMON'; 21 } 22 export function toolRoute(toolId: string): string { 23 return `${SOURCE_ROUTE[routeSourceOf(toolId)]}/${toolSlug(toolId)}`; 24 } 25 26 export interface Detection { type: string; value: string } 27 28 export interface Technique { 29 id: string; 30 toolId: string; 31 toolName: string; 32 name?: string; 33 source: SourceId; 34 platform: Platform[]; 35 capability: Capability[]; 36 nativeCategory: string[]; 37 command: string; 38 description?: string; 39 usecase?: string; 40 mitre: string[]; 41 privilege?: string; 42 context?: string; 43 requires?: string[]; 44 services?: string[]; 45 fullPath?: string[]; 46 toolType?: string; 47 detection?: Detection[]; 48 references: string[]; 49 added?: boolean; 50 verifyNote?: string; 51 environment?: string[]; 52 aliases?: string[]; 53 availability?: string; 54 verification?: string; 55 reviewedAt?: string; 56 compatibility?: string; 57 expected?: string; 58 troubleshooting?: string; 59 sideEffects?: string; 60 restore?: string; 61 template?: { command: string; shell: 'posix' | 'powershell'; variables: { key: string; label: string; default: string }[] }; 62 } 63 64 export interface Tool { 65 id: string; 66 name: string; 67 source: SourceId; 68 platform: Platform[]; 69 toolType?: string; 70 aliases?: string[]; 71 fullPath?: string[]; 72 author?: string; 73 created?: string; 74 contributors?: string[]; 75 references: string[]; 76 count: number; 77 } 78 79 export interface CatalogFilters { 80 platform: string[]; 81 capability: string[]; 82 source: string[]; 83 query: string; 84 addedOnly: boolean; 85 context?: string[]; 86 requires?: string[]; 87 services?: string[]; 88 environment?: string[]; 89 availability?: string[]; 90 verification?: string[]; 91 } 92 93 export const EMPTY_FILTERS: CatalogFilters = { 94 platform: [], capability: [], source: [], query: '', addedOnly: false, 95 }; 96 97 // OR within a facet (any ticked value present on the technique), AND across 98 // facets — the conventional faceted-search behaviour. 99 const inFacet = (values: string[], selected: string[]) => 100 selected.length === 0 || selected.some((s) => values.includes(s)); 101 102 export function searchText(t: Technique): string { 103 return [t.toolName, t.name, t.command, t.description, t.usecase, t.context, t.privilege, 104 ...(t.aliases || []), ...(t.requires || []), ...(t.services || []), ...(t.environment || []), ...(t.nativeCategory || []), ...(t.mitre || [])] 105 .filter(Boolean) 106 .join(' ') 107 .toLowerCase(); 108 } 109 110 export function matchesQuery(t: Technique, q: string): boolean { 111 const hay = searchText(t); 112 return q.toLowerCase().trim().split(/\s+/).every(word => hay.includes(word)); 113 } 114 115 export const FACET_KEYS = ['platform', 'capability', 'source', 'context', 'requires', 'services', 'environment', 'availability', 'verification'] as const; 116 export type FacetKey = (typeof FACET_KEYS)[number]; 117 export function facetValues(t: Technique, key: FacetKey): string[] { 118 const v = key === 'context' ? (t.context || t.privilege) : t[key]; 119 return Array.isArray(v) ? v : v ? [v] : []; 120 } 121 122 export function matches(t: Technique, f: CatalogFilters): boolean { 123 if (f.addedOnly && !t.added) return false; 124 if (!inFacet(t.platform, f.platform)) return false; 125 if (!inFacet(t.capability, f.capability)) return false; 126 if (!inFacet([t.source], f.source)) return false; 127 for (const key of FACET_KEYS.slice(3)) if (!inFacet(facetValues(t, key), f[key] || [])) return false; 128 if (!matchesQuery(t, f.query)) return false; 129 return true; 130 } 131 132 export function filterTechniques(techniques: Technique[], f: CatalogFilters): Technique[] { 133 return techniques.filter((t) => matches(t, f)); 134 } 135 136 export function countActive(f: CatalogFilters): number { 137 return FACET_KEYS.reduce((n, k) => n + (f[k]?.length || 0), 0); 138 } 139 140 export function isEmpty(f: CatalogFilters): boolean { 141 return countActive(f) === 0 && !f.query && !f.addedOnly; 142 } 143 144 // URL <-> filters. Comma-joined multi-values so a narrowed view is shareable. 145 const PARAM_KEYS = { platform: 'p', capability: 'c', source: 's', context: 'ctx', requires: 'access', services: 'svc', environment: 'env', availability: 'available', verification: 'verified' } as const; 146 147 export function filtersToSearch(f: CatalogFilters): string { 148 const params = new URLSearchParams(); 149 for (const [key, param] of Object.entries(PARAM_KEYS) as [keyof typeof PARAM_KEYS, string][]) { 150 const vals = f[key]; 151 if (vals?.length) params.set(param, vals.join(',')); 152 } 153 if (f.addedOnly) params.set('new', '1'); 154 const q = f.query.trim(); 155 if (q) params.set('q', q); 156 return params.toString(); 157 } 158 159 export function filtersFromSearch(search: string): CatalogFilters { 160 const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); 161 const list = (k: string) => (params.get(k) || '').split(',').map((s) => s.trim()).filter(Boolean); 162 return { 163 ...Object.fromEntries(Object.entries(PARAM_KEYS).slice(3).filter(([, param]) => list(param).length).map(([key, param]) => [key, list(param)])), 164 platform: list(PARAM_KEYS.platform), 165 capability: list(PARAM_KEYS.capability), 166 source: list(PARAM_KEYS.source), 167 query: params.get('q') || '', 168 addedOnly: params.get('new') === '1', 169 }; 170 }