jsonld.ts (5499B)
1 // Structured data (schema.org JSON-LD) builders. Pure: every function takes 2 // what it needs and returns a plain object; Base.astro serialises whatever a 3 // page hands it. Kept small on purpose — the goal is a correct WebSite / 4 // TechArticle / BreadcrumbList / Dataset graph, not every property Google lists. 5 6 import { SOURCE_META, SOURCES } from './taxonomy'; 7 import type { SourceId } from './taxonomy'; 8 import type { Technique, Tool } from './techniques'; 9 10 export type JsonLd = Record<string, unknown>; 11 12 export const SITE_NAME = 'DÆMONBins'; 13 export const SITE_FALLBACK = 'https://lotl.daemon-sec.xyz'; 14 const GPL = 'https://www.gnu.org/licenses/gpl-3.0.html'; 15 16 const abs = (site: URL | undefined, path: string) => new URL(path, site ?? SITE_FALLBACK).href; 17 18 const publisher = { 19 '@type': 'Organization', 20 name: 'DÆMON', 21 url: 'https://daemon-sec.xyz', 22 }; 23 24 /** Home: the site + its search box (SearchAction → /catalog?q=). */ 25 export function website(site: URL | undefined): JsonLd { 26 const root = abs(site, '/'); 27 return { 28 '@context': 'https://schema.org', 29 '@type': 'WebSite', 30 '@id': `${root}#website`, 31 name: SITE_NAME, 32 alternateName: 'the Off-the-Land Almanac', 33 url: root, 34 publisher, 35 license: GPL, 36 potentialAction: { 37 '@type': 'SearchAction', 38 target: { '@type': 'EntryPoint', urlTemplate: `${abs(site, '/catalog')}?q={search_term_string}` }, 39 'query-input': 'required name=search_term_string', 40 }, 41 }; 42 } 43 44 /** The merged dataset itself — on /credits and the catalog. */ 45 export function dataset( 46 site: URL | undefined, 47 counts: { techniques: number; tools: number; bySource?: Record<string, number> }, 48 commits?: Record<string, string | null | undefined>, 49 ): JsonLd { 50 const upstream: SourceId[] = SOURCES.filter((s) => s !== 'DAEMON'); 51 return { 52 '@context': 'https://schema.org', 53 '@type': 'Dataset', 54 '@id': `${abs(site, '/')}#dataset`, 55 name: `${SITE_NAME} technique index`, 56 description: `${counts.techniques} command references across ${counts.tools} tools, from GTFOBins, LOLBAS, WADComs and LOOBins with DÆMON-authored additions. ATT&CK mappings are included where available.`, 57 url: abs(site, '/catalog'), 58 license: GPL, 59 isAccessibleForFree: true, 60 creator: publisher, 61 keywords: ['living off the land', 'LOLBins', 'GTFOBins', 'LOLBAS', 'WADComs', 'MITRE ATT&CK', 'privilege escalation'], 62 isBasedOn: upstream.map((id) => { 63 const s = SOURCE_META[id]; 64 const sha = commits?.[id.toLowerCase()]; 65 return { 66 '@type': 'Dataset', 67 name: s.label, 68 url: s.homepage, 69 sameAs: s.repo, 70 license: GPL, 71 ...(sha ? { version: sha } : {}), 72 }; 73 }), 74 distribution: [{ 75 '@type': 'DataDownload', 76 encodingFormat: 'application/json', 77 contentUrl: abs(site, '/data/techniques.json'), 78 }], 79 }; 80 } 81 82 /** /catalog and the deck indexes. */ 83 export function collectionPage(site: URL | undefined, path: string, name: string, description: string): JsonLd { 84 return { 85 '@context': 'https://schema.org', 86 '@type': 'CollectionPage', 87 name, 88 description, 89 url: abs(site, path), 90 isPartOf: { '@id': `${abs(site, '/')}#website` }, 91 about: { '@id': `${abs(site, '/')}#dataset` }, 92 }; 93 } 94 95 /** Catalog → Source → Tool. */ 96 export function breadcrumbs(site: URL | undefined, items: { name: string; path: string }[]): JsonLd { 97 return { 98 '@context': 'https://schema.org', 99 '@type': 'BreadcrumbList', 100 itemListElement: items.map((it, i) => ({ 101 '@type': 'ListItem', 102 position: i + 1, 103 name: it.name, 104 item: abs(site, it.path), 105 })), 106 }; 107 } 108 109 /** One tool page: the binary and its techniques as a TechArticle. */ 110 export function techArticle( 111 site: URL | undefined, 112 path: string, 113 tool: Tool, 114 techniques: Technique[], 115 source: SourceId, 116 ): JsonLd { 117 const s = SOURCE_META[source]; 118 const caps = [...new Set(techniques.flatMap((t) => t.capability))]; 119 const mitre = [...new Set(techniques.flatMap((t) => t.mitre || []))]; 120 const platforms = [...new Set(techniques.flatMap((t) => t.platform))]; 121 const upstream = tool.references?.find((r) => /gtfobins|lolbas|wadcoms/.test(r)) || s.homepage; 122 return { 123 '@context': 'https://schema.org', 124 '@type': 'TechArticle', 125 headline: `${tool.name} — ${s.label}`, 126 name: tool.name, 127 description: `${tool.name}: ${techniques.length} living-off-the-land technique${techniques.length === 1 ? '' : 's'} from ${s.label}. Commands, MITRE ATT&CK mapping, detection and references.`, 128 url: abs(site, path), 129 mainEntityOfPage: abs(site, path), 130 inLanguage: 'en', 131 isPartOf: { '@id': `${abs(site, '/')}#website` }, 132 articleSection: s.label, 133 keywords: [tool.name, ...caps, ...platforms, ...(tool.aliases || [])], 134 about: mitre.map((id) => ({ 135 '@type': 'Thing', 136 name: `MITRE ATT&CK ${id}`, 137 url: `https://attack.mitre.org/techniques/${id.replace('.', '/')}/`, 138 })), 139 isBasedOn: upstream, 140 license: GPL, 141 author: { '@type': 'Organization', name: s.author.replace(/\s*&\s*contributors$/, ''), url: s.homepage }, 142 publisher, 143 ...(tool.created ? { dateCreated: tool.created } : {}), 144 }; 145 } 146 147 /** 148 * Serialise for an inline <script type="application/ld+json">. `<` is escaped 149 * so upstream text (tool descriptions, names) can never close the script tag. 150 */ 151 export function serialize(ld: JsonLd | JsonLd[]): string { 152 return JSON.stringify(ld).replace(/</g, '\\u003c'); 153 }