2026-08-30 21:53:31 +02:00
'use strict' ;
const REPLACE_STRINGS = [ 'HackTheBox - ' , 'VulnHub - ' , 'UHC - ' ];
const PAGE_SIZE = 50 ;
const POC_PREVIEW = 5 ;
2026-09-01 19:01:07 +02:00
const ADVISORY_PREVIEW = 3 ;
2026-08-30 21:53:31 +02:00
const TREND_ROWS = 20 ;
2026-08-30 23:24:19 +02:00
const MIN_QUERY = 2 ;
2026-09-01 19:01:07 +02:00
const POC_FIELDS = [ 'poc' , 'nuclei' , 'msf' , 'edb' , 'vulhub' , 'collections' ];
2026-09-01 22:08:20 +02:00
const FILTER_OPTIONS = {
severity : [
[ 'CRITICAL' , 'CRITICAL' ], [ 'HIGH' , 'HIGH' ], [ 'MEDIUM' , 'MEDIUM' ],
2026-09-01 23:56:04 +02:00
[ 'LOW' , 'LOW' ]
2026-09-01 22:08:20 +02:00
],
source : [
[ 'GITHUB' , 'GITHUB' ], [ 'GHSA' , 'GHSA' ], [ 'REFERENCE' , 'REFERENCE' ],
[ 'NUCLEI' , 'NUCLEI' ], [ 'MSF' , 'METASPLOIT' ], [ 'EDB' , 'EXPLOITDB' ],
[ 'VULHUB' , 'VULHUB' ], [ 'COLLECTIONS' , 'COLLECTIONS' ]
]
};
2026-08-30 21:53:31 +02:00
const state = {
query : '' ,
2026-10-07 01:23:59 +02:00
mode : 'LANDED' ,
2026-09-04 02:16:13 +02:00
sort : 'RELEVANCE' ,
2026-08-30 21:53:31 +02:00
shown : PAGE_SIZE ,
descOpen : new Set (),
pocOpen : new Set (),
2026-09-01 19:01:07 +02:00
advisoryOpen : new Set (),
kevOnly : false ,
2026-09-01 22:08:20 +02:00
filters : {
2026-09-01 23:16:51 +02:00
severity : new Set ( FILTER_OPTIONS . severity . map (([ value ]) => value )),
source : new Set ( FILTER_OPTIONS . source . map (([ value ]) => value ))
2026-09-01 22:08:20 +02:00
},
2026-08-30 21:53:31 +02:00
ready : false ,
2026-10-06 21:51:21 +02:00
loadFailed : false ,
2026-08-30 21:53:31 +02:00
results : []
};
2024-05-26 14:06:40 +02:00
2026-08-30 21:53:31 +02:00
let dataset = [];
let repoMeta = {};
2026-08-31 00:13:01 +02:00
let kev = {};
2026-09-01 05:11:31 +02:00
let ratings = {};
2026-09-01 19:01:07 +02:00
let metadata = {};
2026-09-01 05:24:39 +02:00
let epss = {};
2026-08-30 21:53:31 +02:00
let indexMeta = null ;
2025-12-17 17:09:25 +01:00
2026-08-30 21:53:31 +02:00
/* ---- formatting -------------------------------------------------------- */
function escapeHTML ( value ) {
return String ( value == null ? '' : value ). replace ( /[&<>"']/g , ch => ({
'&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : '''
}[ ch ]));
2025-12-18 07:13:08 +01:00
}
2026-08-30 21:53:31 +02:00
function formatCount ( n ) {
return n . toLocaleString ( 'en-US' );
2025-12-18 07:13:08 +01:00
}
2026-09-02 17:17:28 +02:00
/** 1st, 2nd, 3rd, 4th. A hard-coded "th" printed 51th on 21,100 CVEs. */
function ordinal ( n ) {
const tens = n % 100 ;
if ( tens >= 11 && tens <= 13 ) return ` ${ n } th` ;
return ` ${ n }${ [ 'th' , 'st' , 'nd' , 'rd' ][ n % 10 ] || 'th' } ` ;
}
2026-08-30 21:53:31 +02:00
function formatStars ( n ) {
return n >= 1000 ? ( n / 1000 ). toFixed ( 1 ) + 'k' : String ( n );
2024-05-26 14:06:40 +02:00
}
2026-08-30 21:53:31 +02:00
function hoursSince ( iso ) {
if ( ! iso ) return null ;
const then = Date . parse ( iso . length === 10 ? iso + 'T00:00:00Z' : iso );
if ( Number . isNaN ( then )) return null ;
2026-10-06 21:51:21 +02:00
return Math . max ( 0 , ( Date . now () - then ) / 36e5 );
2025-12-18 10:36:27 +01:00
}
2026-10-07 01:23:59 +02:00
/** UTC date of an ISO date or timestamp, read as build_pages.py reads it: a
* time without a zone is UTC. Empty when there is no readable date. */
function utcDay ( value ) {
if ( typeof value !== 'string' || ! /^\d{4}-\d{2}-\d{2}/ . test ( value )) return '' ;
const time = Date . parse ( /T[\d:.]+$/ . test ( value ) ? value + 'Z' : value );
return Number . isNaN ( time ) ? '' : new Date ( time ). toISOString (). slice ( 0 , 10 );
2025-12-18 10:36:27 +01:00
}
2026-08-30 21:53:31 +02:00
/** Compact age for the PoC columns: 3h, 3d, 2w, 5mo, 2y. */
function shortAge ( hours ) {
if ( hours == null ) return '' ;
if ( hours < 24 ) return Math . max ( 1 , Math . round ( hours )) + 'h' ;
const days = hours / 24 ;
if ( days < 14 ) return Math . round ( days ) + 'd' ;
if ( days < 60 ) return Math . round ( days / 7 ) + 'w' ;
if ( days < 730 ) return Math . round ( days / 30 ) + 'mo' ;
return Math . round ( days / 365 ) + 'y' ;
2025-12-18 10:36:27 +01:00
}
2026-08-31 00:22:44 +02:00
// github.com serves more than repositories; an attachment or an advisory has
// no owner and no stars, and reads as nonsense in an owner / repo row.
const NOT_A_REPO = new Set ([ 'user-attachments' , 'advisories' , 'security' , 'orgs' ,
'apps' , 'marketplace' , 'sponsors' , 'topics' , 'collections' , 'settings' , 'notifications' ]);
2026-08-30 21:53:31 +02:00
function repoFromUrl ( url ) {
const match = /^https?:\/\/(?:www\.)?github\.com\/([^/#?]+)\/([^/#?]+)/i . exec ( url || '' );
2026-10-06 22:06:54 +02:00
if ( ! match || NOT_A_REPO . has ( match [ 1 ]. toLowerCase ()) ||
/^\/security\/advisories(?:\/|$)/i . test ( url . slice ( match [ 0 ]. length ))) return null ;
2026-08-30 21:53:31 +02:00
return { owner : match [ 1 ], repo : match [ 2 ]. replace ( /\.git$/i , '' ) };
2025-12-18 10:19:09 +01:00
}
2026-08-30 21:53:31 +02:00
/** Everything after the host, for links that are not GitHub repositories. */
function plainLinkLabel ( url ) {
try {
const parsed = new URL ( url );
return parsed . host . replace ( /^www\./ , '' ) + parsed . pathname . replace ( /\/$/ , '' );
} catch ( err ) {
return url ;
}
2024-05-27 02:42:49 +02:00
}
2026-08-30 21:53:31 +02:00
/* ---- matching ---------------------------------------------------------- */
2024-05-27 02:42:49 +02:00
2026-08-30 21:53:31 +02:00
function normalizeToSpaces ( value ) {
return value . toLowerCase (). replace ( /[^a-z0-9]+/g , ' ' ). trim ();
2024-05-26 14:06:40 +02:00
}
2026-08-30 21:53:31 +02:00
function buildLooseRegex ( value ) {
const compact = value . toLowerCase (). replace ( /[^a-z0-9]+/g , '' );
if ( compact . length < 4 ) return null ;
const escaped = compact . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' );
return new RegExp ( escaped . split ( '' ). join ( '[^a-z0-9]*' ));
2026-08-30 16:58:41 +02:00
}
2026-08-30 21:53:31 +02:00
function buildMatcher ( term ) {
const raw = term . toLowerCase (). trim ();
if ( ! raw ) return null ;
const isPhrase = /\s/ . test ( raw );
const normalized = raw . replace ( /[^a-z0-9]+/g , '' );
return {
raw ,
isPhrase ,
phrase : isPhrase ? normalizeToSpaces ( raw ) : '' ,
loose : ! isPhrase && normalized . length >= 4 ? buildLooseRegex ( raw ) : null
};
2026-08-30 16:58:41 +02:00
}
2026-08-30 17:15:53 +02:00
// Plain substring matching made "rce" hit "open source" and "sudo" hit
// "pseudo". Require the term to start a word; matching into the end of one is
// still allowed so that "word" finds "wordpress".
function wordStart ( text , term ) {
2026-08-30 21:53:31 +02:00
let at = text . indexOf ( term );
while ( at > 0 ) {
const code = text . charCodeAt ( at - 1 );
if ( ! (( code > 96 && code < 123 ) || ( code > 47 && code < 58 ))) return at ;
at = text . indexOf ( term , at + 1 );
}
return at ;
2026-08-30 17:15:53 +02:00
}
// A term in the opening of an advisory is what it is about; one buried deep in
// the prose is usually incidental. Coarse buckets so that a few characters of
// difference never outweighs the newest-first tie-break.
function leadBonus ( index ) {
2026-08-30 21:53:31 +02:00
if ( index < 0 ) return 0 ;
if ( index < 80 ) return 80 ;
if ( index < 300 ) return 40 ;
return 0 ;
}
function pocText ( entry ) {
if ( entry . _pocText === undefined ) {
2026-09-01 19:01:07 +02:00
entry . _pocText = entryLinks ( entry ). join ( ' ' ). toLowerCase ();
2026-08-30 21:53:31 +02:00
}
return entry . _pocText ;
}
2026-09-01 19:01:07 +02:00
function entryLinks ( entry ) {
if ( entry . _allLinks === undefined ) {
2026-10-06 23:49:47 +02:00
entry . _allLinks = uniqueSourceLinks (
POC_FIELDS . flatMap ( field => entry [ field ] || []),
POC_FIELDS . slice ( 1 ). flatMap ( field => entry [ field ] || []));
2026-09-01 19:01:07 +02:00
}
return entry . _allLinks ;
}
2026-08-30 21:53:31 +02:00
function pocSpace ( entry ) {
if ( entry . _pocSpace === undefined ) {
entry . _pocSpace = normalizeToSpaces ( pocText ( entry ));
}
return entry . _pocSpace ;
2026-08-30 17:15:53 +02:00
}
2026-08-30 16:58:41 +02:00
function descSpace ( entry ) {
2026-08-30 21:53:31 +02:00
if ( entry . _descSpace === undefined ) {
entry . _descSpace = normalizeToSpaces ( entry . _descText );
}
return entry . _descSpace ;
}
function scoreEntry ( entry , matcher ) {
if ( ! matcher || ! matcher . raw ) return 0 ;
if ( matcher . isPhrase ) {
const phrase = matcher . phrase ;
if ( ! phrase ) return 0 ;
2026-09-04 02:16:13 +02:00
if ( entry . _titleSpace && wordStart ( entry . _titleSpace , phrase ) >= 0 ) return 300 ;
2026-08-30 21:53:31 +02:00
const at = wordStart ( descSpace ( entry ), phrase );
if ( at >= 0 ) return 200 + leadBonus ( at );
if ( wordStart ( pocSpace ( entry ), phrase ) >= 0 ) return 80 ;
return 0 ;
}
const raw = matcher . raw ;
if ( entry . _cveText . includes ( raw )) return 600 ;
2026-09-04 02:16:13 +02:00
// The record's own title, vendor and product name what it is about; a hit
// there outranks any position in the prose.
if ( entry . _titleText && wordStart ( entry . _titleText , raw ) >= 0 ) return 400 ;
2026-08-30 21:53:31 +02:00
const at = wordStart ( entry . _descText , raw );
if ( at >= 0 ) return 240 + leadBonus ( at );
if ( wordStart ( pocText ( entry ), raw ) >= 0 ) return 80 ;
if ( matcher . loose ) {
if ( matcher . loose . test ( entry . _descText )) return 160 ;
if ( matcher . loose . test ( pocText ( entry ))) return 60 ;
}
return 0 ;
}
2026-08-30 22:18:44 +02:00
/** How many of an entry's links carry every search term. */
function countLinkHits ( entry , matchers ) {
let hits = 0 ;
2026-09-01 19:01:07 +02:00
for ( const url of entryLinks ( entry )) {
2026-08-30 22:18:44 +02:00
const text = url . toLowerCase ();
if ( matchers . every ( m => wordStart ( text , m . raw ) >= 0 || ( m . loose && m . loose . test ( text )))) {
hits += 1 ;
if ( hits >= 999 ) break ;
}
}
return hits ;
}
2026-09-01 19:01:07 +02:00
function canonicalCvss ( id ) {
const rows = (( metadata [ id ] || {}). cvss || []);
if ( ! Array . isArray ( rows ) || ! Array . isArray ( rows [ 0 ])) return null ;
const row = rows [ 0 ];
return {
score : Number ( row [ 1 ]),
severity : String ( row [ 2 ] || '' ). toUpperCase ()
};
}
2026-09-04 02:16:13 +02:00
// Cached per entry. A filter pass asked the metadata for every one of 82,000
// entries on every keystroke; the answer only changes when a data file lands,
// which bumps the epoch.
let severityEpoch = 0 ;
2026-09-01 19:01:07 +02:00
function entrySeverity ( entry ) {
2026-09-04 02:16:13 +02:00
if ( entry . _sevEpoch === severityEpoch ) return entry . _sev ;
2026-09-01 19:01:07 +02:00
const score = canonicalCvss ( entry . cve );
2026-09-04 02:16:13 +02:00
let severity = '' ;
2026-09-01 19:01:07 +02:00
if ( score && [ 'NONE' , 'LOW' , 'MEDIUM' , 'HIGH' , 'CRITICAL' ]. includes ( score . severity )) {
2026-09-04 02:16:13 +02:00
severity = score . severity ;
} else {
const fallback = String (( ratings [ entry . cve ] || {}). severity || '' ). toUpperCase ();
severity = [ 'NONE' , 'LOW' , 'MEDIUM' , 'HIGH' , 'CRITICAL' ]. includes ( fallback ) ? fallback : '' ;
2026-09-01 19:01:07 +02:00
}
2026-09-04 02:16:13 +02:00
entry . _sev = severity ;
entry . _sevEpoch = severityEpoch ;
return severity ;
2026-09-01 19:01:07 +02:00
}
function hasActiveFilters () {
2026-09-01 23:16:51 +02:00
return state . kevOnly || Object . entries ( state . filters ). some (
([ kind , filter ]) => filter . size !== FILTER_OPTIONS [ kind ]. length
);
2026-09-01 19:01:07 +02:00
}
2026-09-01 22:08:20 +02:00
function entrySources ( entry ) {
2026-09-04 02:16:13 +02:00
if ( entry . _sources ) return entry . _sources ;
2026-09-01 22:08:20 +02:00
const sources = new Set ();
2026-09-01 19:01:07 +02:00
const fields = {
NUCLEI : 'nuclei' , MSF : 'msf' , EDB : 'edb' , VULHUB : 'vulhub' , COLLECTIONS : 'collections'
};
2026-09-01 22:08:20 +02:00
for ( const [ source , field ] of Object . entries ( fields )) {
if (( entry [ field ] || []). length ) sources . add ( source );
}
for ( const url of entry . poc || []) {
2026-10-06 22:06:54 +02:00
if ( /^https?:\/\/(?:www\.)?github\.com\/(?:advisories|[^/]+\/[^/]+\/security\/advisories)\/GHSA-/i . test ( url )) {
2026-09-01 22:08:20 +02:00
sources . add ( 'GHSA' );
} else if ( repoFromUrl ( url )) {
sources . add ( 'GITHUB' );
} else {
sources . add ( 'REFERENCE' );
}
2026-09-01 19:01:07 +02:00
}
2026-09-04 02:16:13 +02:00
entry . _sources = sources ;
2026-09-01 22:08:20 +02:00
return sources ;
}
function matchesMultiFilter ( values , filter ) {
2026-09-01 23:16:51 +02:00
for ( const value of filter ) {
2026-09-01 22:08:20 +02:00
if ( values . has ( value )) return true ;
2026-09-01 19:01:07 +02:00
}
2026-09-01 22:08:20 +02:00
return false ;
2026-09-01 19:01:07 +02:00
}
function matchesFilters ( entry ) {
if ( state . kevOnly && ! kev [ entry . cve ]) return false ;
2026-09-01 22:08:20 +02:00
if ( ! matchesMultiFilter ( entrySources ( entry ), state . filters . source )) return false ;
2026-09-02 17:17:28 +02:00
// ALL has to mean all. The options list only names the four scored bands, so
// testing membership hid every CVE nobody has scored yet even with nothing
// filtered: 41 of them were unreachable by search, including by exact ID.
if ( state . filters . severity . size === FILTER_OPTIONS . severity . length ) return true ;
const severity = entrySeverity ( entry );
return severity ? matchesMultiFilter ( new Set ([ severity ]), state . filters . severity ) : false ;
2026-09-01 19:01:07 +02:00
}
2026-09-04 02:16:13 +02:00
/* ---- word index -------------------------------------------------------- */
// Every keystroke used to scan 28 MB of description text, and for terms of
// four characters or more the loose regex then ran against every entry that
// did not match, about 80,000 regex tests a keystroke. The index maps each
// word to the entries carrying it, so a term is a prefix lookup in a sorted
// word list and the scan only touches the candidates. It is built in slices
// after the index loads so typing never waits on it; until then the scan does
// the work exactly as before.
const wordIndex = { words : null , postings : new Map (), ready : false , built : 0 };
function tokensOf ( text ) {
return text ? text . match ( /[a-z0-9]+/g ) || [] : [];
}
function indexSlice ( budget ) {
const started = performance . now ();
const postings = wordIndex . postings ;
while ( wordIndex . built < dataset . length ) {
const at = wordIndex . built ;
const entry = dataset [ at ];
const words = ( entry . _descText + ' ' + entry . _titleText + ' ' + pocSpace ( entry )). match ( /[a-z0-9]+/g );
if ( words ) {
// Entries arrive in order, so a repeat within one entry is always the
// last id on the list and needs no set to catch it.
for ( const word of words ) {
const list = postings . get ( word );
if ( ! list ) postings . set ( word , [ at ]);
else if ( list [ list . length - 1 ] !== at ) list . push ( at );
}
}
wordIndex . built += 1 ;
if (( at & 127 ) === 0 && performance . now () - started > budget ) break ;
}
if ( wordIndex . built < dataset . length ) {
yieldThen (() => indexSlice ( budget ));
return ;
}
wordIndex . words = [... postings . keys ()]. sort ();
wordIndex . ready = true ;
wordIndex . elapsed = Math . round ( performance . now () - wordIndex . startedAt );
2026-10-06 22:39:15 +02:00
if ( state . ready && state . query . length >= MIN_QUERY && ! state . results . length ) render ();
2026-09-04 02:16:13 +02:00
}
// Hands the thread back between slices. A timer would do, except that a
// background tab clamps timers to once a second and the build took a minute
// there; a message port yields to the event loop without that throttle.
const yieldPort = new MessageChannel ();
let yieldNext = null ;
yieldPort . port1 . onmessage = () => {
const next = yieldNext ;
yieldNext = null ;
if ( next ) next ();
};
function yieldThen ( fn ) {
yieldNext = fn ;
yieldPort . port2 . postMessage ( null );
}
function buildWordIndex () {
wordIndex . postings = new Map ();
wordIndex . words = null ;
wordIndex . built = 0 ;
wordIndex . ready = false ;
wordIndex . startedAt = performance . now ();
indexSlice ( 32 );
}
// The slice of the sorted word list that starts with the prefix.
function wordRange ( prefix ) {
const words = wordIndex . words ;
if ( ! words ) return [ 0 , 0 ];
let low = 0 ;
let high = words . length ;
while ( low < high ) {
const mid = ( low + high ) >> 1 ;
if ( words [ mid ] < prefix ) low = mid + 1 ;
else high = mid ;
}
const start = low ;
high = words . length ;
while ( low < high ) {
const mid = ( low + high ) >> 1 ;
if ( words [ mid ]. startsWith ( prefix )) low = mid + 1 ;
else high = mid ;
}
return [ start , low ];
}
// How many of the terms each entry can satisfy, by word. A term has to start a
// word, and may run on into the end of one, so its last part is a prefix and
// any earlier parts are whole words: "pan-os" is the word "pan" followed by a
// word starting "os". The scoring pass still checks the literal text; this
// only decides which entries are worth its time.
2026-09-04 02:59:24 +02:00
function candidateCounts ( groups ) {
2026-09-04 02:16:13 +02:00
const size = dataset . length ;
const total = new Uint8Array ( size );
const stampOf = new Int32Array ( size );
2026-09-04 02:59:24 +02:00
const hit = new Uint8Array ( size );
2026-09-04 02:16:13 +02:00
let stamp = 0 ;
2026-09-04 02:59:24 +02:00
for ( const group of groups ) {
hit . fill ( 0 );
for ( const matcher of group ) {
const parts = tokensOf ( matcher . isPhrase ? matcher . phrase : matcher . raw );
if ( ! parts . length ) return null ;
const partCount = new Uint8Array ( size );
parts . forEach (( part , index ) => {
stamp += 1 ;
const bump = at => {
if ( stampOf [ at ] !== stamp ) {
stampOf [ at ] = stamp ;
partCount [ at ] += 1 ;
}
};
if ( index === parts . length - 1 ) {
const [ start , end ] = wordRange ( part );
for ( let i = start ; i < end ; i ++ ) {
for ( const at of wordIndex . postings . get ( wordIndex . words [ i ])) bump ( at );
}
} else {
for ( const at of wordIndex . postings . get ( part ) || []) bump ( at );
2026-09-04 02:16:13 +02:00
}
2026-09-04 02:59:24 +02:00
});
// A CVE id is not a word in anybody's description, so it is checked apart.
const raw = matcher . raw ;
for ( let at = 0 ; at < size ; at ++ ) {
if ( partCount [ at ] === parts . length || dataset [ at ]. _cveText . includes ( raw )) hit [ at ] = 1 ;
2026-09-04 02:16:13 +02:00
}
}
2026-09-04 02:59:24 +02:00
for ( let at = 0 ; at < size ; at ++ ) if ( hit [ at ]) total [ at ] += 1 ;
2026-09-04 02:16:13 +02:00
}
return total ;
}
2026-09-04 02:59:24 +02:00
function scoreGroup ( entry , group ) {
let best = 0 ;
for ( const matcher of group ) {
const score = scoreEntry ( entry , matcher );
if ( score > best ) best = score ;
}
return best ;
}
/* ---- what people type versus what the records say ----------------------- */
// Security slang against NVD prose. "sqli" found 843 CVEs and "sql injection"
// 12,719; a term expands to its phrases and an entry matches on any of them.
const ALIASES = {
sqli : [ 'sql injection' ],
xss : [ 'cross-site scripting' , 'cross site scripting' ],
rce : [ 'remote code execution' ],
lpe : [ 'privilege escalation' ],
privesc : [ 'privilege escalation' ],
eop : [ 'elevation of privilege' , 'privilege escalation' ],
lfi : [ 'local file inclusion' ],
rfi : [ 'remote file inclusion' ],
xxe : [ 'xml external entity' ],
csrf : [ 'cross-site request forgery' ],
ssrf : [ 'server-side request forgery' ],
ssti : [ 'server-side template injection' ],
idor : [ 'insecure direct object reference' ],
dos : [ 'denial of service' ],
cmdi : [ 'command injection' ],
traversal : [ 'path traversal' , 'directory traversal' ],
deserialization : [ 'deserialization of untrusted data' , 'insecure deserialization' ],
bypass : [ 'authentication bypass' , 'authorization bypass' ],
overflow : [ 'buffer overflow' , 'heap overflow' , 'stack overflow' , 'integer overflow' ]
};
// The names a vulnerability is known by, when the query is exactly that name.
// The record it belongs to leads whatever the prose and repositories say.
const NAMED = {
log4shell : 'CVE-2021-44228' , heartbleed : 'CVE-2014-0160' , eternalblue : 'CVE-2017-0144' ,
eternalromance : 'CVE-2017-0145' , wannacry : 'CVE-2017-0144' , printnightmare : 'CVE-2021-34527' ,
zerologon : 'CVE-2020-1472' , spring4shell : 'CVE-2022-22965' , proxyshell : 'CVE-2021-34473' ,
proxylogon : 'CVE-2021-26855' , proxynotshell : 'CVE-2022-41040' , citrixbleed : 'CVE-2023-4966' ,
citrixbleed2 : 'CVE-2025-5777' , regresshion : 'CVE-2024-6387' , bluekeep : 'CVE-2019-0708' ,
shellshock : 'CVE-2014-6271' , dirtypipe : 'CVE-2022-0847' , dirtycow : 'CVE-2016-5195' ,
follina : 'CVE-2022-30190' , ghostcat : 'CVE-2020-1938' , looneytunables : 'CVE-2023-4911' ,
text4shell : 'CVE-2022-42889' , pwnkit : 'CVE-2021-4034' , baronsamedit : 'CVE-2021-3156' ,
moveit : 'CVE-2023-34362' , smbghost : 'CVE-2020-0796' , petitpotam : 'CVE-2021-36942' ,
hivenightmare : 'CVE-2021-36934' , serioussam : 'CVE-2021-36934' , sambacry : 'CVE-2017-7494' ,
drupalgeddon : 'CVE-2014-3704' , drupalgeddon2 : 'CVE-2018-7600' , curveball : 'CVE-2020-0601' ,
sigred : 'CVE-2020-1350' , poodle : 'CVE-2014-3566' , meltdown : 'CVE-2017-5754' , spectre : 'CVE-2017-5753' ,
krack : 'CVE-2017-13077' , zenbleed : 'CVE-2023-20593' , downfall : 'CVE-2022-40982' ,
terrapin : 'CVE-2023-48795' , xzbackdoor : 'CVE-2024-3094'
};
function namedCve ( query ) {
return NAMED [ query . toLowerCase (). replace ( /[^a-z0-9]+/g , '' )] || null ;
}
// A typo has no word in the index at all, which a real term nearly never has.
// The closest word one edit away, weighed by how many records use it, stands
// in and the status line says so.
function withinOneEdit ( a , b ) {
if ( a === b ) return true ;
if ( Math . abs ( a . length - b . length ) > 1 ) return false ;
let i = 0 ;
while ( i < a . length && i < b . length && a [ i ] === b [ i ]) i ++ ;
if ( a . length === b . length ) {
if ( a . slice ( i + 1 ) === b . slice ( i + 1 )) return true ;
return i + 1 < a . length && a [ i ] === b [ i + 1 ] && a [ i + 1 ] === b [ i ] && a . slice ( i + 2 ) === b . slice ( i + 2 );
}
return a . length > b . length ? a . slice ( i + 1 ) === b . slice ( i ) : b . slice ( i + 1 ) === a . slice ( i );
}
function closestWord ( term ) {
const [ start , end ] = wordRange ( term [ 0 ]);
let best = null ;
let bestSize = 0 ;
for ( let i = start ; i < end ; i ++ ) {
const word = wordIndex . words [ i ];
if ( Math . abs ( word . length - term . length ) > 1 || ! withinOneEdit ( term , word )) continue ;
const size = wordIndex . postings . get ( word ). length ;
if ( size > bestSize ) { best = word ; bestSize = size ; }
}
return best ;
}
function correctTerms ( terms ) {
const changes = [];
const fixed = terms . map ( term => {
const bare = term . replace ( /^-?"|"$/g , '' ). toLowerCase ();
if ( term [ 0 ] === '-' || term [ 0 ] === '"' || ! /^[a-z0-9]{4,}$/ . test ( bare ) || ALIASES [ bare ]) return term ;
const [ start , end ] = wordRange ( bare );
if ( end > start ) return term ;
const alt = closestWord ( bare );
if ( ! alt ) return term ;
changes . push ([ bare , alt ]);
return alt ;
});
return changes . length ? { query : fixed . join ( ' ' ), changes } : null ;
}
2026-09-04 02:16:13 +02:00
/* ---- search ------------------------------------------------------------ */
const SORTS = [[ 'RELEVANCE' , 'RELEVANCE' ], [ 'NEWEST' , 'NEWEST' ], [ 'POCS' , 'MOST POCS' ]];
// Coarse steps so that one extra repository never decides the order on its own.
function linkBucket ( count ) {
return count >= 100 ? 4 : count >= 20 ? 3 : count >= 5 ? 2 : count >= 2 ? 1 : 0 ;
}
// Within a score tier the evidence decides, newest last: whether CISA lists
// it, how many PoCs exist, how likely exploitation is. Falling straight
// through to the year put five 2026 records above regreSSHion for "openssh"
// and hid Log4Shell from the first page of "log4j".
function compareRelevance ( a , b ) {
if ( b . _score !== a . _score ) return b . _score - a . _score ;
if ( b . _hits !== a . _hits ) return b . _hits - a . _hits ;
const ka = kev [ a . cve ] ? 1 : 0 ;
const kb = kev [ b . cve ] ? 1 : 0 ;
if ( kb !== ka ) return kb - ka ;
const la = linkBucket ( entryLinks ( a ). length );
const lb = linkBucket ( entryLinks ( b ). length );
if ( lb !== la ) return lb - la ;
const ea = Math . round ((( epss [ a . cve ] || [ 0 ])[ 0 ] || 0 ) * 100 );
const eb = Math . round ((( epss [ b . cve ] || [ 0 ])[ 0 ] || 0 ) * 100 );
if ( eb !== ea ) return eb - ea ;
if ( b . _year !== a . _year ) return b . _year - a . _year ;
return b . _num - a . _num ;
}
function compareNewest ( a , b ) {
return ( b . _year - a . _year ) || ( b . _num - a . _num ) || ( b . _score - a . _score );
}
function comparePocs ( a , b ) {
return ( entryLinks ( b ). length - entryLinks ( a ). length ) || ( b . _score - a . _score )
|| ( b . _year - a . _year ) || ( b . _num - a . _num );
}
2026-09-04 02:59:24 +02:00
function runSearch ( query , scan = false , corrected = false ) {
2026-08-30 21:53:31 +02:00
const terms = query . match ( /-?"[^"]+"|-?\S+/g ) || [];
const cleaned = terms . map ( t => t . replace ( /^(-?)"/ , '$1' ). replace ( /"$/ , '' ));
const positive = cleaned . filter ( t => t && t [ 0 ] !== '-' );
const matchers = positive . map ( buildMatcher ). filter ( Boolean );
2026-09-04 02:59:24 +02:00
const groups = matchers . map ( m => [ m , ...( ALIASES [ m . raw ] || []). map ( buildMatcher ). filter ( Boolean )]);
2026-08-30 21:53:31 +02:00
// Unquoted words are matched independently, so "active directory" also hits a
// description holding "active session" and "working directory". Reward the
// words appearing together to keep those below real matches.
const adjacent = positive . length > 1 ? positive . join ( ' ' ). toLowerCase () : '' ;
const negatives = cleaned
. filter ( t => t && t [ 0 ] === '-' )
. map ( t => t . slice ( 1 ))
. filter ( Boolean )
. map ( buildMatcher )
. filter ( Boolean );
2026-09-04 02:59:24 +02:00
const counts = ! scan && wordIndex . ready && groups . length ? candidateCounts ( groups ) : null ;
2026-08-30 21:53:31 +02:00
const results = [];
2026-08-30 23:24:19 +02:00
results . pocTotal = 0 ;
2026-09-04 02:16:13 +02:00
for ( let at = 0 ; at < dataset . length ; at ++ ) {
2026-09-04 02:59:24 +02:00
if ( counts && counts [ at ] !== groups . length ) continue ;
2026-09-04 02:16:13 +02:00
const entry = dataset [ at ];
2026-09-01 19:01:07 +02:00
if ( ! matchesFilters ( entry )) continue ;
2026-08-30 21:53:31 +02:00
let score = 0 ;
let matched = true ;
2026-08-30 22:18:44 +02:00
let describes = false ;
2026-09-04 02:59:24 +02:00
for ( const group of groups ) {
const termScore = scoreGroup ( entry , group );
2026-08-30 21:53:31 +02:00
if ( termScore === 0 ) { matched = false ; break ; }
2026-08-30 22:18:44 +02:00
if ( termScore >= 160 ) describes = true ;
2026-08-30 21:53:31 +02:00
score += termScore ;
2026-08-30 16:58:41 +02:00
}
2026-08-30 21:53:31 +02:00
if ( ! matched ) continue ;
if ( negatives . some ( m => scoreEntry ( entry , m ) > 0 )) continue ;
if ( adjacent ) {
const at = wordStart ( entry . _descText , adjacent );
2026-08-30 22:18:44 +02:00
if ( at >= 0 ) { score += 300 + leadBonus ( at ); describes = true ; }
2026-08-30 21:53:31 +02:00
else if ( wordStart ( pocText ( entry ), adjacent ) >= 0 ) score += 100 ;
}
entry . _score = score ;
2026-08-31 04:00:54 +02:00
// A term nobody's advisory text uses, "log4shell" for one, leaves every match tied
2026-08-30 22:18:44 +02:00
// at the weakest tier, where newest-first is meaningless. How many of the
// linked PoCs carry the term says far more than which CVE is newer.
entry . _hits = describes ? 0 : countLinkHits ( entry , matchers );
2026-09-01 19:01:07 +02:00
results . pocTotal += entryLinks ( entry ). length ;
2026-08-30 21:53:31 +02:00
results . push ( entry );
}
2026-09-04 02:16:13 +02:00
// The index finds a term where it starts a word. Its loose form, "log-4j"
// for log4j, can only be found by the full pass, which is worth paying for
// when the indexed pass came back nearly empty and not otherwise.
2026-09-04 02:59:24 +02:00
let out = results ;
if ( counts && results . length < 5 && matchers . some ( m => m . loose )) out = runSearch ( query , true , corrected );
if ( ! out . length && ! scan && ! corrected && wordIndex . ready ) {
const fix = correctTerms ( terms );
if ( fix ) {
const again = runSearch ( fix . query , false , true );
again . corrected = fix . changes ;
return again ;
}
}
2026-09-04 02:16:13 +02:00
2026-09-04 02:59:24 +02:00
const star = scan || corrected ? null : namedCve ( query );
if ( star ) {
let entry = out . find ( e => e . cve === star );
if ( ! entry ) {
entry = dataset . find ( e => e . cve === star );
if ( entry && matchesFilters ( entry )) {
entry . _score = 0 ;
entry . _hits = 0 ;
out . pocTotal += entryLinks ( entry ). length ;
out . push ( entry );
}
}
if ( entry ) entry . _score += 100000 ;
}
out . sort ( state . sort === 'NEWEST' ? compareNewest : state . sort === 'POCS' ? comparePocs : compareRelevance );
return out ;
2026-08-30 16:58:41 +02:00
}
2025-12-17 18:54:18 +01:00
function prepareDataset ( raw ) {
2026-08-30 21:53:31 +02:00
if ( ! Array . isArray ( raw )) return [];
const out = [];
for ( const entry of raw ) {
const cve = ( entry . cve || '' ). trim ();
2026-09-01 19:01:07 +02:00
if ( ! cve ) continue ;
for ( const field of POC_FIELDS ) {
if ( ! Array . isArray ( entry [ field ])) entry [ field ] = [];
}
if ( ! entryLinks ( entry ). length ) continue ;
2026-08-30 21:53:31 +02:00
const parts = cve . split ( '-' );
entry . _cveText = cve . toLowerCase ();
entry . _descText = REPLACE_STRINGS
. reduce (( desc , str ) => desc . replace ( str , '' ), entry . desc || '' )
. toLowerCase ();
entry . _year = parseInt ( parts [ 1 ], 10 ) || 0 ;
entry . _num = parseInt ( parts [ 2 ], 10 ) || 0 ;
2026-09-04 02:16:13 +02:00
// Title, vendor and product, once the records carry them.
const named = [ entry . title || '' , ...( entry . vendor || []), ...( entry . product || [])]. join ( ' ' );
entry . _titleText = named . toLowerCase ();
entry . _titleSpace = normalizeToSpaces ( entry . _titleText );
2026-08-30 21:53:31 +02:00
out . push ( entry );
}
return out ;
2025-12-17 18:54:18 +01:00
}
2026-08-30 21:53:31 +02:00
/* ---- rendering --------------------------------------------------------- */
const el = {
input : document . querySelector ( '[data-search]' ),
status : document . querySelector ( '[data-status]' ),
results : document . querySelector ( '[data-results]' ),
trending : document . querySelector ( '[data-trending]' ),
2026-10-07 01:23:59 +02:00
trendDate : document . querySelector ( '[data-trend-date]' ),
2026-08-30 21:53:31 +02:00
trendRows : document . querySelector ( '[data-trend-rows]' ),
2026-09-01 04:46:30 +02:00
statTotal : document . querySelector ( '[data-stat-total]' ),
statPocs : document . querySelector ( '[data-stat-pocs]' ),
statKev : document . querySelector ( '[data-stat-kev]' ),
2026-09-01 19:01:07 +02:00
refreshed : document . querySelector ( '[data-refreshed]' ),
2026-09-04 01:49:25 +02:00
filterChips : {
severity : document . querySelector ( '[data-filter-chips="severity"]' ),
source : document . querySelector ( '[data-filter-chips="source"]' )
2026-09-01 22:08:20 +02:00
},
2026-09-01 19:01:07 +02:00
kevOnly : document . querySelector ( '[data-filter-kev]' ),
clearFilters : document . querySelector ( '[data-clear-filters]' )
2024-05-26 14:06:40 +02:00
};
2026-09-01 05:24:39 +02:00
const CURATED = [
2026-09-01 22:08:20 +02:00
{ match : 'github.com/advisories/GHSA-' , tag : 'GHSA' , hint : 'GitHub reviewed advisory with reproducible exploit material' ,
label : url => url . split ( '/' ). pop () },
2026-09-01 19:01:07 +02:00
{ match : '/zan8in/afrog/' , tag : 'AFROG' , hint : 'Runnable afrog CVE template' ,
label : url => decodeURIComponent ( url . split ( '/' ). pop ()) },
{ match : '/chaitin/xray/' , tag : 'XRAY' , hint : 'Runnable xray CVE template' ,
label : url => decodeURIComponent ( url . split ( '/' ). pop ()) },
{ match : '/helloexp/0day/' , tag : '0DAY' , hint : 'CVE-specific exploit collection path' ,
label : url => decodeURIComponent ( url . split ( '/' ). pop ()) },
{ match : '/tzwlhack/Vulnerability/' , tag : 'POC' , hint : 'CVE-specific reproduction guide' ,
label : url => decodeURIComponent ( url . split ( '/' ). pop ()) },
2026-09-01 05:24:39 +02:00
{ match : '/nuclei-templates/' , tag : 'NUCLEI' , hint : 'Runnable nuclei detection template' ,
label : url => url . split ( '/' ). pop () },
{ match : '/metasploit-framework/' , tag : 'MSF' , hint : 'Metasploit module' ,
label : url => url . split ( '/modules/' ). pop (). replace ( /\.rb$/ , '' ) },
{ match : 'exploit-db.com/exploits/' , tag : 'EDB' , hint : 'ExploitDB entry' ,
2026-09-01 19:01:07 +02:00
label : url => 'exploit-db.com/' + url . split ( '/' ). pop () },
{ match : '/vulhub/vulhub/tree/' , tag : 'VULHUB' , hint : 'Runnable Vulhub environment' ,
label : url => url . split ( '/tree/master/' ). pop () }
2026-09-01 05:24:39 +02:00
];
2026-10-07 01:23:59 +02:00
// Multi-CVE research collections. A path into one is labelled below the
// collection's exploit directory, without the collection's stars.
const COLLECTION_SOURCES = {
'google/security-research' : { tag : 'GOOGLE' , hint : 'Google Security Research exploit' , root : 'pocs/' },
'github/securitylab' : { tag : 'GHSL' , hint : 'GitHub Security Lab exploit' , root : 'SecurityExploits/' },
'tenable/poc' : { tag : 'TENABLE' , hint : 'Tenable Research proof of concept' , root : '' },
'pedrib/poc' : { tag : 'PEDRIB' , hint : 'pedrib/PoC exploit or advisory' , root : '' }
};
/** A curated link's path below its collection's exploit directory, such as
* linux/kernelctf/CVE-2024-26642_cos. Empty for a repository root. */
function artifactPath ( url ) {
const parsed = repoFromUrl ( url );
if ( ! parsed ) return '' ;
let parts = new URL ( url ). pathname . split ( '/' ). filter ( Boolean ). slice ( 2 );
if ( parts [ 0 ] === 'tree' || parts [ 0 ] === 'blob' ) parts = parts . slice ( 2 );
let path = parts . join ( '/' );
try {
path = decodeURIComponent ( path );
} catch ( err ) {
// A malformed escape is shown as written.
}
const root = ( COLLECTION_SOURCES [ ` ${ parsed . owner } / ${ parsed . repo } ` . toLowerCase ()] || {}). root ;
return root && path . startsWith ( root ) && path . length > root . length ? path . slice ( root . length ) : path ;
}
function pocRow ( url , artifact = false ) {
2026-09-01 05:24:39 +02:00
const curated = CURATED . find ( source => url . includes ( source . match ));
if ( curated ) {
2026-09-01 05:11:31 +02:00
return '<div class="poc-row"><span class="poc-name">' +
2026-09-01 05:24:39 +02:00
`<span class="poc-tag is- ${ curated . tag . toLowerCase () } " title=" ${ escapeHTML ( curated . hint ) } "> ${ curated . tag } </span>` +
`<a href=" ${ escapeHTML ( url ) } " target="_blank" rel="noopener"> ${ escapeHTML ( curated . label ( url )) } </a>` +
2026-09-01 05:11:31 +02:00
'</span><span class="poc-stars"></span><span class="poc-age"></span></div>' ;
}
2026-08-30 21:53:31 +02:00
const parsed = repoFromUrl ( url );
const href = escapeHTML ( url );
2026-08-31 04:00:54 +02:00
// A gist or an advisory has no owner and no stars; the star and age columns
// stay empty rather than inventing a repository shape for it.
2026-08-30 21:53:31 +02:00
if ( ! parsed ) {
2026-08-31 04:00:54 +02:00
return '<div class="poc-row"><span class="poc-name">' +
2026-10-06 22:06:54 +02:00
`<a class="plain" href=" ${ href } " target="_blank" rel="nofollow noopener"> ${ escapeHTML ( plainLinkLabel ( url )) } </a>` +
2026-08-31 04:00:54 +02:00
'</span><span class="poc-stars"></span><span class="poc-age"></span></div>' ;
2026-08-30 21:53:31 +02:00
}
2026-10-07 01:23:59 +02:00
// One exploit in a shared repository: the repository's stars and push date are not its own.
const path = artifact ? artifactPath ( url ) : '' ;
if ( path ) {
const name = ` ${ parsed . owner } / ${ parsed . repo } ` ;
const source = COLLECTION_SOURCES [ name . toLowerCase ()] || { tag : parsed . owner . toUpperCase (), hint : name };
return '<div class="poc-row"><span class="poc-name">' +
`<span class="poc-tag is- ${ escapeHTML ( source . tag . toLowerCase ()) } " title=" ${ escapeHTML ( source . hint ) } "> ${ escapeHTML ( source . tag ) } </span>` +
`<a href=" ${ href } " target="_blank" rel="noopener"> ${ escapeHTML ( path ) } </a>` +
'</span><span class="poc-stars"></span><span class="poc-age"></span></div>' ;
}
2026-08-31 04:00:54 +02:00
const meta = repoMeta [( parsed . owner + '/' + parsed . repo ). toLowerCase ()];
2026-08-30 21:53:31 +02:00
const stars = meta ? meta [ 0 ] : null ;
const hours = meta ? hoursSince ( meta [ 1 ]) : null ;
2026-08-31 04:00:54 +02:00
const popular = stars != null && stars >= 500 ? ' is-popular' : '' ;
// The glyph stays dim at every value so the column reads as numbers.
const starCell = stars == null ? '' : ` ${ formatStars ( stars ) } <span class="star">★</span>` ;
return '<div class="poc-row">' +
`<span class="poc-name"> ${ escapeHTML ( parsed . owner ) } <span class="poc-sep"> / </span>` +
2026-10-06 22:06:54 +02:00
`<a href=" ${ href } " target="_blank" rel="nofollow noopener"> ${ escapeHTML ( parsed . repo ) } </a></span>` +
2026-08-31 04:00:54 +02:00
`<span class="poc-stars ${ popular } "> ${ starCell } </span>` +
2026-08-30 21:53:31 +02:00
`<span class="poc-age"> ${ escapeHTML ( shortAge ( hours )) } </span></div>` ;
}
2024-05-26 14:06:40 +02:00
2026-08-30 22:18:44 +02:00
function compact ( value ) {
return value . toLowerCase (). replace ( /[^a-z0-9]/g , '' );
}
/** Best links first. A repository named after the CVE is a proof of concept for
* it; a 5k-star scanner that merely mentions it is not, so stars only order
* repositories that are already about this CVE. Links with no star count keep
2026-08-31 04:00:54 +02:00
* their original order behind both: a reference has no repository to rank. */
2026-08-30 21:53:31 +02:00
function rankedLinks ( entry ) {
if ( entry . _ranked ) return entry . _ranked ;
2026-08-30 22:18:44 +02:00
const id = compact ( entry . cve );
2026-08-30 21:53:31 +02:00
const scored = ( entry . poc || []). map (( url , index ) => {
const parsed = repoFromUrl ( url );
const meta = parsed && repoMeta [( parsed . owner + '/' + parsed . repo ). toLowerCase ()];
2026-08-30 22:18:44 +02:00
return {
url ,
index ,
dedicated : compact ( url ). includes ( id ) ? 1 : 0 ,
stars : meta ? meta [ 0 ] : - 1
};
2026-08-30 21:53:31 +02:00
});
2026-08-30 22:18:44 +02:00
scored . sort (( a , b ) =>
( b . dedicated - a . dedicated ) || ( b . stars - a . stars ) || ( a . index - b . index ));
2026-09-01 19:01:07 +02:00
// Curated entries lead: templates, exploit archives, modules and runnable
// environments can be used as they stand without first reading a repository.
2026-09-01 22:08:20 +02:00
entry . _ranked = uniqueSourceLinks ([
2026-09-01 19:01:07 +02:00
...( entry . nuclei || []), ...( entry . msf || []), ...( entry . edb || []), ...( entry . vulhub || []),
...( entry . collections || []),
2026-09-01 05:24:39 +02:00
... scored . map ( item => item . url )
2026-10-06 23:49:47 +02:00
], POC_FIELDS . slice ( 1 ). flatMap ( field => entry [ field ] || []));
2026-08-30 21:53:31 +02:00
return entry . _ranked ;
}
2025-12-17 17:09:25 +01:00
2026-09-01 19:01:07 +02:00
function cvssTooltip ( id ) {
const rows = (( metadata [ id ] || {}). cvss || []);
if ( ! Array . isArray ( rows )) return '' ;
return rows . filter ( Array . isArray ). map ( row => {
const source = row [ 4 ] ? ` by ${ row [ 4 ] } ` : '' ;
const assessment = row [ 5 ] ? ` ( ${ row [ 5 ] } )` : '' ;
return `CVSS ${ row [ 0 ] } ${ row [ 1 ] } ${ row [ 2 ] }${ source }${ assessment } \n ${ row [ 3 ] || '' } ` . trim ();
}). join ( '\n\n' );
}
function advisoryRow ( item ) {
if ( ! Array . isArray ( item ) || ! item [ 0 ]) return '' ;
const url = String ( item [ 0 ]);
const tags = Array . isArray ( item [ 1 ]) ? item [ 1 ]. map ( String ) : [];
const tag = tags . includes ( 'Vendor Advisory' ) ? 'VENDOR' : tags . includes ( 'Patch' ) ? 'PATCH' : 'ADVISORY' ;
return '<div class="poc-row"><span class="poc-name">' +
`<span class="poc-tag is-advisory" title=" ${ escapeHTML ( tags . join ( ', ' ) || 'Advisory' ) } "> ${ tag } </span>` +
`<a class="plain" href=" ${ escapeHTML ( url ) } " target="_blank" rel="noopener"> ${ escapeHTML ( plainLinkLabel ( url )) } </a>` +
'</span><span class="poc-stars"></span><span class="poc-age"></span></div>' ;
}
2026-10-06 23:49:47 +02:00
function normalizedLink ( url , preservePath = false ) {
2026-09-01 22:08:20 +02:00
const parsed = repoFromUrl ( String ( url || '' ));
2026-10-06 23:49:47 +02:00
if ( parsed ) {
const key = `github: ${ parsed . owner . toLowerCase () } / ${ parsed . repo . toLowerCase () } ` ;
if ( ! preservePath ) return key ;
const location = new URL ( url );
const path = location . pathname . split ( '/' ). filter ( Boolean ). slice ( 2 ). join ( '/' );
return ` ${ key } / ${ path }${ location . search }${ location . hash } ` ;
}
2026-09-01 19:01:07 +02:00
return String ( url || '' ). replace ( /\/$/ , '' );
}
2026-10-06 23:49:47 +02:00
function uniqueSourceLinks ( urls , curated = []) {
2026-09-01 22:08:20 +02:00
const seen = new Set ();
2026-10-06 23:49:47 +02:00
const artifacts = new Set ( curated );
const sources = new Set ( curated . map ( url => normalizedLink ( url )));
2026-09-01 22:08:20 +02:00
return urls . filter ( url => {
2026-10-06 23:49:47 +02:00
if ( ! artifacts . has ( url ) && sources . has ( normalizedLink ( url ))) return false ;
const key = normalizedLink ( url , artifacts . has ( url ));
2026-09-01 22:08:20 +02:00
if ( seen . has ( key )) return false ;
seen . add ( key );
return true ;
});
}
2026-08-30 21:53:31 +02:00
function resultRow ( entry ) {
const id = entry . cve ;
const open = state . descOpen . has ( id );
const all = state . pocOpen . has ( id );
const links = rankedLinks ( entry );
const visible = all ? links : links . slice ( 0 , POC_PREVIEW );
2026-10-07 01:23:59 +02:00
const artifacts = new Set ( POC_FIELDS . slice ( 1 ). flatMap ( field => entry [ field ] || []));
2026-08-31 04:00:54 +02:00
// Nothing to expand means no control: a line reading "all repositories
// shown" under a list of three is chrome that answers a question nobody has.
const moreButton = links . length > POC_PREVIEW
? `<button type="button" class="poc-more" data-toggle-poc=" ${ escapeHTML ( id ) } ">` +
` ${ all ? '↑ show fewer' : '+ ' + formatCount ( links . length - POC_PREVIEW ) + ' more' } </button>`
2026-08-30 22:18:44 +02:00
: '' ;
2026-08-30 21:53:31 +02:00
2026-09-01 19:01:07 +02:00
const advisoryOpen = state . advisoryOpen . has ( id );
2026-10-06 23:49:47 +02:00
const pocLinks = new Set ( entryLinks ( entry ). map ( url => normalizedLink ( url )));
2026-09-01 19:01:07 +02:00
const advisories = (( metadata [ id ] || {}). advisories || [])
. filter ( item => Array . isArray ( item ) && ! pocLinks . has ( normalizedLink ( item [ 0 ])));
const shownAdvisories = advisoryOpen ? advisories : advisories . slice ( 0 , ADVISORY_PREVIEW );
const advisoryMore = advisories . length > ADVISORY_PREVIEW
? `<button type="button" class="poc-more" data-toggle-advisory=" ${ escapeHTML ( id ) } ">` +
` ${ advisoryOpen ? '↑ show fewer' : '+ ' + formatCount ( advisories . length - ADVISORY_PREVIEW ) + ' more' } </button>`
: '' ;
const advisoryHtml = advisories . length
? `<div class="advisory-list"><div class="advisory-head">CREDIBLE ADVISORIES <span> ${ formatCount ( advisories . length ) } </span></div>` +
` ${ shownAdvisories . map ( advisoryRow ). join ( '' ) }${ advisoryMore } </div>`
: '' ;
// NVD is canonical and carries every CVSS generation. Nuclei remains a
// fallback for a template scored before NVD or its CNA publishes a vector.
const canonical = canonicalCvss ( id );
const fallback = ratings [ id ] || {};
const severity = canonical ? canonical . severity : entrySeverity ( entry );
const cvss = canonical ? canonical . score : fallback . cvss ;
const tooltip = canonical ? cvssTooltip ( id ) : String ( fallback . cvss_vector || '' );
const severityChip = severity
? `<span class="chip chip-sev is- ${ escapeHTML ( severity . toLowerCase ()) } " ${ tooltip ? ` title=" ${ escapeHTML ( tooltip ) } "` : '' } >` +
` ${ escapeHTML ( severity ) }${ cvss != null ? ' ' + escapeHTML ( cvss ) : '' } </span>`
2026-09-01 05:11:31 +02:00
: '' ;
2026-09-01 05:24:39 +02:00
// FIRST's feed covers nearly the whole index; a template's own score is the
// fallback for the handful it misses.
2026-09-04 00:36:11 +02:00
const scored = epss [ id ] || null ;
2026-09-01 05:24:39 +02:00
const epssChip = scored
2026-09-02 17:17:28 +02:00
? `<span class="chip chip-epss ${ scored [ 0 ] >= 0.1 ? ' is-hot' : '' } " title="EPSS: estimated chance of exploitation in the next 30 days, ${ ordinal ( Math . round ( scored [ 1 ] * 100 )) } percentile">` +
2026-09-01 05:24:39 +02:00
`EPSS ${ ( scored [ 0 ] * 100 ). toFixed ( scored [ 0 ] >= 0.1 ? 0 : 1 ) } %</span>`
2026-09-01 05:11:31 +02:00
: '' ;
2026-08-31 00:13:01 +02:00
const flagged = kev [ id ];
const kevChip = flagged
2026-08-31 04:00:54 +02:00
? `<span class="chip chip-kev" title="Listed in CISA's Known Exploited Vulnerabilities catalogue on ${ escapeHTML ( flagged [ 0 ]) }${ flagged [ 1 ] ? ', used in ransomware campaigns' : '' } ">` +
`KEV ${ flagged [ 1 ] ? ' RANSOMWARE' : '' } </span>`
2026-08-31 00:13:01 +02:00
: '' ;
2026-08-30 21:53:31 +02:00
const dates = [];
2026-08-31 04:00:54 +02:00
if ( entry . published ) dates . push ( `published ${ entry . published } ` );
if ( entry . modified ) dates . push ( `modified ${ entry . modified } ` );
2026-08-30 21:53:31 +02:00
const dateHtml = dates . length
2026-08-31 04:00:54 +02:00
? `<div class="result-dates"> ${ dates . map ( d => `<span> ${ escapeHTML ( d ) } </span>` ). join ( '' ) } </div>`
2026-08-30 21:53:31 +02:00
: '' ;
return `<div class="result-row">
<div class="result-meta">
2026-09-02 04:00:28 +02:00
<a class="result-id" href="/ ${ encodeURIComponent ( id ) } "> ${ escapeHTML ( id ) } </a>
2026-08-30 21:53:31 +02:00
<div class="result-pocs"> ${ formatCount ( links . length ) } linked PoC ${ links . length === 1 ? '' : 's' } </div>
${ dateHtml }
2026-09-01 05:11:31 +02:00
<div class="chips"> ${ kevChip }${ severityChip }${ epssChip }
2026-09-02 04:00:28 +02:00
<a class="chip" href="https://nvd.nist.gov/vuln/detail/ ${ encodeURIComponent ( id ) } " target="_blank" rel="noopener">NVD ↗</a>
2026-09-01 19:01:07 +02:00
<a class="chip" href="https://www.cve.org/CVERecord?id= ${ encodeURIComponent ( id ) } " target="_blank" rel="noopener">CVE.ORG ↗</a>
2026-08-31 04:00:54 +02:00
</div>
2026-08-30 21:53:31 +02:00
</div>
<div class="result-body">
2026-09-04 02:32:22 +02:00
${ entry . title ? `<p class="result-title"> ${ escapeHTML ( entry . title ) } </p>` : '' }
2026-08-30 21:53:31 +02:00
<p class="result-desc ${ open ? ' is-open' : '' } "> ${ escapeHTML ( entry . desc || '' ) } </p>
<button type="button" class="expander" data-toggle-desc=" ${ escapeHTML ( id ) } "> ${ open ? '↑ collapse' : '↓ full description' } </button>
<div class="poc-list">
2026-10-07 01:23:59 +02:00
${ visible . map ( url => pocRow ( url , artifacts . has ( url ))). join ( '' ) }
2026-08-30 22:18:44 +02:00
${ moreButton }
2026-08-30 21:53:31 +02:00
</div>
2026-09-01 19:01:07 +02:00
${ advisoryHtml }
2026-08-30 21:53:31 +02:00
</div>
</div>` ;
}
2025-12-17 17:09:25 +01:00
2026-08-30 22:18:44 +02:00
/** Hide the expander on descriptions that already fit; a button that does
* nothing when clicked is worse than no button. */
function pruneExpanders () {
for ( const row of el . results . querySelectorAll ( '.result-row' )) {
const desc = row . querySelector ( '.result-desc' );
const button = row . querySelector ( '.expander' );
if ( ! desc || ! button ) continue ;
if ( ! desc . classList . contains ( 'is-open' ) && desc . scrollHeight <= desc . clientHeight + 1 ) {
button . hidden = true ;
}
}
}
2026-08-30 21:53:31 +02:00
function renderResults ( elapsed ) {
const results = state . results ;
el . trending . hidden = true ;
el . results . hidden = false ;
if ( ! results . length ) {
2026-09-01 19:01:07 +02:00
const subject = state . query . length >= MIN_QUERY
? ` for ${ escapeHTML ( state . query ) } `
: ' for the active filters' ;
el . results . innerHTML = `<div class="empty">No results ${ subject } </div>` ;
2026-09-02 17:17:28 +02:00
// The live region is the only account of this a screen reader gets, and
// returning here left it reading "loading index…" over a page that had
// already given up.
if ( elapsed != null ) {
el . status . textContent = `no results in ${ Math . max ( 1 , Math . round ( elapsed )) } ms` ;
}
2026-08-30 21:53:31 +02:00
return ;
}
const shown = results . slice ( 0 , state . shown );
2026-08-30 23:24:19 +02:00
const pocTotal = results . pocTotal ;
2026-08-30 21:53:31 +02:00
const remaining = results . length - shown . length ;
const footer = remaining > 0
2026-08-31 04:00:54 +02:00
? `<button type="button" class="poc-more" style="padding:9px 16px" data-more-results>+ ${ formatCount ( remaining ) } more matching CVEs</button>`
2026-08-30 21:53:31 +02:00
: '' ;
2026-09-04 02:16:13 +02:00
const sortSwitch = SORTS . map (([ key , label ]) =>
`<button type="button" data-sort=" ${ key } " aria-pressed=" ${ String ( state . sort === key ) } "> ${ label } </button>`
). join ( '' );
2026-08-31 04:00:54 +02:00
el . results . innerHTML = `<div class="panel-head">
2026-08-30 21:53:31 +02:00
<h2>Results</h2>
2026-09-02 17:17:28 +02:00
<span class="panel-count"> ${ formatCount ( results . length ) } CVE ${ results . length === 1 ? '' : 's' } · ${ formatCount ( pocTotal ) } PoC ${ pocTotal === 1 ? '' : 's' } </span>
2026-09-04 02:16:13 +02:00
<div class="switch switch-sort" role="group" aria-label="Order"> ${ sortSwitch } </div>
2026-08-30 21:53:31 +02:00
</div>
<div class="col-head"><span>CVE</span><span>DESCRIPTION / POC LINKS</span></div>
${ shown . map ( resultRow ). join ( '' ) }${ footer } ` ;
2026-08-30 22:18:44 +02:00
pruneExpanders ();
2026-08-30 21:53:31 +02:00
if ( elapsed != null ) {
2026-09-04 02:59:24 +02:00
const note = results . corrected
? results . corrected . map (([ typed , used ]) => ` ${ used } for ${ typed } ` ). join ( ', ' ) + ' · '
: '' ;
2026-10-06 22:06:54 +02:00
el . status . textContent = ` ${ formatCount ( results . length ) } CVE ${ results . length === 1 ? '' : 's' } · ${ note } matched in ${ Math . max ( 1 , Math . round ( elapsed )) } ms` ;
2026-08-30 21:53:31 +02:00
}
}
2024-05-26 14:06:40 +02:00
2026-10-07 01:23:59 +02:00
// Switch value to the trending_poc.json list it shows. RECENT is the old
// newest-first value, still sent by cached pages.
const TREND_LISTS = { LANDED : 'landed' , TRENDING : 'items' , RECENT : 'landed' };
function trendRow ( item , landed ) {
// A path in a collection does not have the collection's stars.
const stars = item . artifact ? null : item . stars ;
const starCell = stars == null ? '' : ` ${ formatStars ( stars ) } <span class="star">★</span>` ;
// Off GitHub the last path segment is a bare id, so it keeps its host.
const host = String ( item . source || '' ). replace ( /^www\./ , '' );
const name = item . artifact && host && host !== 'github.com' ? ` ${ host } / ${ item . name || '' } ` : item . name ;
2026-09-01 04:46:30 +02:00
// The trending table flags the same catalogue the CVE rows do, so a row worth
// reading first is visible without opening it.
2026-10-07 01:23:59 +02:00
const flagged = item . kev ? '<span class="trend-kev" title="CISA known exploited">KEV</span>' : '' ;
2026-10-06 21:51:21 +02:00
const detail = item . page
? `<a class="trend-detail" href=" ${ escapeHTML ( item . page ) } "> ${ escapeHTML ( item . cve ) } details</a>`
: '' ;
2026-10-07 01:23:59 +02:00
return `<div class="trend-row"><span class="trend-stars ${ stars >= 500 ? ' is-popular' : '' } "> ${ starCell } </span>` +
`<span class="trend-age"> ${ utcDay ( landed ? item . released : item . pushed ) } </span>` +
`<span class="trend-name-cell"> ${ flagged } <a class="trend-name" href=" ${ escapeHTML ( item . url ) } " target="_blank" rel="nofollow noopener"> ${ escapeHTML ( name ) } </a> ${ detail } </span>` +
`<span class="trend-desc"> ${ escapeHTML ( item . desc ) } </span></div>` ;
2026-08-30 21:53:31 +02:00
}
2025-12-17 17:09:25 +01:00
2026-08-30 21:53:31 +02:00
function renderTrending () {
el . results . hidden = true ;
el . trending . hidden = false ;
2026-10-06 21:51:21 +02:00
// Keep the build's rows if the optional feed is still loading or unavailable.
if ( ! indexMeta ) return ;
2026-08-30 21:53:31 +02:00
2026-10-07 01:23:59 +02:00
// Each list arrives ranked and is shown as published, as build_pages.py
// pre-renders it.
const list = TREND_LISTS [ state . mode ];
const landed = list === 'landed' ;
const rows = ( Array . isArray ( indexMeta [ list ]) ? indexMeta [ list ] : []). slice ( 0 , TREND_ROWS );
2026-08-30 21:53:31 +02:00
el . trendRows . innerHTML = rows . length
2026-10-07 01:23:59 +02:00
? rows . map ( item => trendRow ( item , landed )). join ( '' )
: '<div class="empty">No recent PoCs.</div>' ;
if ( el . trendDate ) {
el . trendDate . textContent = landed ? 'RELEASED' : 'UPDATED' ;
el . trendDate . title = landed ? 'First release (UTC)' : 'Latest artifact commit (UTC)' ;
}
2026-08-30 21:53:31 +02:00
2026-09-04 02:16:13 +02:00
document . querySelectorAll ( '.trend-controls .switch button' ). forEach ( button => {
2026-10-07 01:23:59 +02:00
button . setAttribute ( 'aria-pressed' , String ( TREND_LISTS [ button . dataset . mode ] === list ));
2026-08-30 21:53:31 +02:00
});
}
2025-12-18 07:13:08 +01:00
2026-09-01 04:46:30 +02:00
function paintHeroStats () {
if ( ! el . statTotal ) return ;
if ( indexMeta ) {
el . statTotal . textContent = formatCount ( indexMeta . total_cves );
el . statPocs . textContent = formatCount ( indexMeta . with_pocs );
}
const flagged = Object . keys ( kev ). length ;
if ( flagged ) el . statKev . textContent = formatCount ( flagged );
}
2026-08-30 21:53:31 +02:00
function idleStatus () {
2026-09-01 04:46:30 +02:00
// The hero carries the totals now, so the field keeps its slot for timings.
return '' ;
2026-08-30 21:53:31 +02:00
}
2025-12-18 07:13:08 +01:00
2026-09-02 03:36:22 +02:00
/* ---- url state --------------------------------------------------------- */
// A result was unlinkable: every search lived in memory, so nobody could cite
// one and no crawler ever saw a second page. The query rides in ?q= now, which
2026-10-06 21:51:21 +02:00
// is also the route the OpenSearch descriptor declares.
2026-09-02 03:36:22 +02:00
function readURLState () {
const params = new URLSearchParams ( location . search );
const query = ( params . get ( 'q' ) || '' ). trim ();
if ( query ) {
state . query = query ;
el . input . value = query ;
}
if ( params . get ( 'kev' ) === '1' ) state . kevOnly = true ;
2026-09-04 02:16:13 +02:00
const sort = ( params . get ( 'sort' ) || '' ). toUpperCase ();
if ( SORTS . some (([ key ]) => key === sort )) state . sort = sort ;
2026-09-04 01:49:25 +02:00
const short = { severity : 'sev' , source : 'src' };
for ( const [ kind , options ] of Object . entries ( FILTER_OPTIONS )) {
const wanted = ( params . get ( short [ kind ]) || '' ). toUpperCase (). split ( ',' )
. filter ( value => options . some (([ each ]) => each === value ));
if ( wanted . length ) state . filters [ kind ] = new Set ( wanted );
}
2026-09-02 03:36:22 +02:00
}
// replaceState, not pushState: typing a query should not bury the back button
// under one history entry per keystroke.
let urlTimer = null ;
function syncURL () {
clearTimeout ( urlTimer );
urlTimer = setTimeout (() => {
const params = new URLSearchParams ();
if ( state . query . length >= MIN_QUERY ) params . set ( 'q' , state . query );
if ( state . kevOnly ) params . set ( 'kev' , '1' );
2026-09-04 02:16:13 +02:00
if ( state . sort !== 'RELEVANCE' ) params . set ( 'sort' , state . sort . toLowerCase ());
2026-09-04 01:49:25 +02:00
const short = { severity : 'sev' , source : 'src' };
for ( const [ kind , filter ] of Object . entries ( state . filters )) {
if ( filter . size !== FILTER_OPTIONS [ kind ]. length ) {
params . set ( short [ kind ], FILTER_OPTIONS [ kind ]. map (([ value ]) => value ). filter ( value => filter . has ( value )). join ( ',' ));
}
}
2026-09-02 03:36:22 +02:00
const search = params . toString ();
const next = search ? ` ${ location . pathname } ? ${ search } ` : location . pathname ;
if ( next !== location . pathname + location . search ) history . replaceState ( null , '' , next );
}, 300 );
}
2026-08-30 21:53:31 +02:00
function render () {
2026-09-02 03:36:22 +02:00
syncURL ();
2026-09-01 19:01:07 +02:00
if ( state . query . length < MIN_QUERY && ! hasActiveFilters ()) {
2026-10-06 21:51:21 +02:00
el . status . textContent = state . ready ? idleStatus () : state . loadFailed ? 'index unavailable' : 'loading index…' ;
2026-08-30 21:53:31 +02:00
renderTrending ();
return ;
}
if ( ! state . ready ) {
el . results . hidden = false ;
el . trending . hidden = true ;
2026-10-06 21:51:21 +02:00
el . status . textContent = state . loadFailed ? 'index unavailable' : 'loading index…' ;
el . results . innerHTML = state . loadFailed
? '<div class="empty">The CVE index could not be loaded. Reload the page to try again, or browse by year below.</div>'
: '<div class="empty">Loading the CVE index…</div>' ;
2026-08-30 21:53:31 +02:00
return ;
}
const started = performance . now ();
2026-09-01 19:01:07 +02:00
state . results = runSearch ( state . query . length >= MIN_QUERY ? state . query : '' );
2026-08-30 21:53:31 +02:00
renderResults ( performance . now () - started );
}
/* ---- wiring ------------------------------------------------------------ */
2026-08-30 23:24:19 +02:00
// A full pass over the index costs ~200ms, so one per keystroke makes typing
// stutter. 160ms covers an ordinary typing cadence without the wait being felt;
// clearing the field stays instant.
let searchTimer = null ;
2026-08-30 21:53:31 +02:00
el . input . addEventListener ( 'input' , () => {
2026-08-30 23:24:19 +02:00
const next = el . input . value . trim ();
if ( next === state . query ) return ;
state . query = next ;
2026-08-30 21:53:31 +02:00
state . shown = PAGE_SIZE ;
2026-08-30 23:24:19 +02:00
clearTimeout ( searchTimer );
if ( next . length < MIN_QUERY ) {
render ();
return ;
}
searchTimer = setTimeout ( render , 160 );
2026-08-30 21:53:31 +02:00
});
document . querySelector ( '.search' ). addEventListener ( 'submit' , event => event . preventDefault ());
2026-09-04 01:49:25 +02:00
// One chip per value, every one of them on the page. A full set is the idle
// state and draws nothing lit, so the first click narrows to that value; later
// clicks widen the set, and switching the last lit chip off is a clear.
2026-09-01 22:08:20 +02:00
function renderFilterOptions () {
for ( const [ kind , options ] of Object . entries ( FILTER_OPTIONS )) {
2026-09-04 01:49:25 +02:00
el . filterChips [ kind ]. innerHTML = options . map (([ value , label ]) =>
`<button type="button" class="filter-chip is- ${ escapeHTML ( value . toLowerCase ()) } " ` +
`data-filter-kind=" ${ kind } " data-filter-value=" ${ value } " aria-pressed="false" disabled>` +
` ${ escapeHTML ( label ) } </button>`
2026-09-01 23:16:51 +02:00
). join ( '' );
2026-09-01 22:08:20 +02:00
}
}
2026-09-04 01:49:25 +02:00
function enableFilter ( kind ) {
for ( const chip of el . filterChips [ kind ]. querySelectorAll ( '[data-filter-value]' )) chip . disabled = false ;
2026-09-01 22:08:20 +02:00
}
2026-09-04 01:49:25 +02:00
function toggleFilter ( kind , value ) {
const filter = state . filters [ kind ];
if ( filter . size === FILTER_OPTIONS [ kind ]. length ) {
filter . clear ();
filter . add ( value );
} else if ( ! filter . has ( value )) {
filter . add ( value );
} else {
filter . delete ( value );
if ( ! filter . size ) for ( const [ each ] of FILTER_OPTIONS [ kind ]) filter . add ( each );
}
2026-09-01 22:08:20 +02:00
}
2026-09-01 19:01:07 +02:00
function syncFilterControls () {
2026-09-01 22:08:20 +02:00
for ( const kind of Object . keys ( FILTER_OPTIONS )) {
const filter = state . filters [ kind ];
2026-09-04 01:49:25 +02:00
const narrowed = filter . size !== FILTER_OPTIONS [ kind ]. length ;
for ( const chip of el . filterChips [ kind ]. querySelectorAll ( '[data-filter-value]' )) {
chip . setAttribute ( 'aria-pressed' , String ( narrowed && filter . has ( chip . dataset . filterValue )));
2026-09-01 22:08:20 +02:00
}
}
2026-09-01 19:01:07 +02:00
el . kevOnly . setAttribute ( 'aria-pressed' , String ( state . kevOnly ));
el . clearFilters . hidden = ! hasActiveFilters ();
}
2026-09-01 22:08:20 +02:00
for ( const kind of Object . keys ( FILTER_OPTIONS )) {
2026-09-04 01:49:25 +02:00
el . filterChips [ kind ]. addEventListener ( 'click' , event => {
const chip = event . target . closest ( '[data-filter-value]' );
if ( ! chip || chip . disabled ) return ;
toggleFilter ( kind , chip . dataset . filterValue );
2026-09-01 22:08:20 +02:00
state . shown = PAGE_SIZE ;
syncFilterControls ();
render ();
});
}
2026-09-01 19:01:07 +02:00
el . kevOnly . addEventListener ( 'click' , () => {
state . kevOnly = ! state . kevOnly ;
state . shown = PAGE_SIZE ;
syncFilterControls ();
render ();
});
el . clearFilters . addEventListener ( 'click' , () => {
state . kevOnly = false ;
2026-09-01 23:16:51 +02:00
for ( const [ kind , filter ] of Object . entries ( state . filters )) {
filter . clear ();
for ( const [ value ] of FILTER_OPTIONS [ kind ]) filter . add ( value );
2026-09-01 22:08:20 +02:00
}
2026-09-01 19:01:07 +02:00
state . shown = PAGE_SIZE ;
syncFilterControls ();
render ();
});
2026-09-01 22:08:20 +02:00
renderFilterOptions ();
2026-09-01 19:01:07 +02:00
syncFilterControls ();
2026-09-01 04:54:53 +02:00
// Typing anywhere on the page means typing into the search field. The keystroke
// is not swallowed: focusing during keydown lets the character land in the
// field it just moved to, so the first letter is never lost.
document . addEventListener ( 'keydown' , event => {
if ( event . ctrlKey || event . metaKey || event . altKey ) return ;
if ( document . activeElement === el . input ) {
if ( event . key === 'Escape' ) {
el . input . value = '' ;
el . input . dispatchEvent ( new Event ( 'input' , { bubbles : true }));
el . input . blur ();
}
return ;
}
const active = document . activeElement ;
const editing = active && ( active . isContentEditable
2026-09-01 22:08:20 +02:00
|| [ 'INPUT' , 'TEXTAREA' , 'SELECT' , 'BUTTON' ]. includes ( active . tagName ));
2026-09-01 04:54:53 +02:00
if ( editing ) return ;
// A lone slash is the shortcut, so it focuses without typing itself.
if ( event . key === '/' ) {
event . preventDefault ();
el . input . focus ();
return ;
}
if ( event . key . length !== 1 && event . key !== 'Backspace' ) return ;
el . input . focus ();
});
// A pointer landing on the page is not a request to type, so the field is only
// focused up front where a keyboard is the likely input.
if ( window . matchMedia ( '(min-width: 900px)' ). matches ) el . input . focus ();
2026-08-31 04:00:54 +02:00
document . querySelector ( '.switch' ). addEventListener ( 'click' , event => {
2026-08-30 21:53:31 +02:00
const button = event . target . closest ( 'button[data-mode]' );
2026-10-07 01:23:59 +02:00
if ( ! button || ! TREND_LISTS [ button . dataset . mode ]) return ;
2026-08-30 21:53:31 +02:00
state . mode = button . dataset . mode ;
renderTrending ();
});
2024-05-26 14:06:40 +02:00
2026-08-30 21:53:31 +02:00
el . results . addEventListener ( 'click' , event => {
2026-09-04 02:16:13 +02:00
const sort = event . target . closest ( '[data-sort]' );
if ( sort ) {
state . sort = sort . dataset . sort ;
state . shown = PAGE_SIZE ;
render ();
return ;
}
2026-08-30 21:53:31 +02:00
const desc = event . target . closest ( '[data-toggle-desc]' );
const poc = event . target . closest ( '[data-toggle-poc]' );
2026-09-01 19:01:07 +02:00
const advisory = event . target . closest ( '[data-toggle-advisory]' );
2026-08-30 21:53:31 +02:00
const more = event . target . closest ( '[data-more-results]' );
if ( desc ) {
const id = desc . dataset . toggleDesc ;
state . descOpen . has ( id ) ? state . descOpen . delete ( id ) : state . descOpen . add ( id );
} else if ( poc ) {
const id = poc . dataset . togglePoc ;
state . pocOpen . has ( id ) ? state . pocOpen . delete ( id ) : state . pocOpen . add ( id );
2026-09-01 19:01:07 +02:00
} else if ( advisory ) {
const id = advisory . dataset . toggleAdvisory ;
state . advisoryOpen . has ( id ) ? state . advisoryOpen . delete ( id ) : state . advisoryOpen . add ( id );
2026-08-30 21:53:31 +02:00
} else if ( more ) {
state . shown += PAGE_SIZE ;
} else {
return ;
}
renderResults ( null );
2024-05-26 14:06:40 +02:00
});
2026-08-30 21:53:31 +02:00
async function loadJSON ( url , options ) {
const response = await fetch ( url , options );
if ( ! response . ok ) throw new Error ( ` ${ url } ( ${ response . status } )` );
return response . json ();
}
( async () => {
2026-10-06 21:51:21 +02:00
readURLState ();
syncFilterControls ();
render ();
// Trending is optional. A slow feed must not hold up a bookmarked search.
loadJSON ( '/trending_poc.json' , { cache : 'no-store' }). then ( meta => {
2026-08-30 21:53:31 +02:00
indexMeta = meta ;
const minutes = Math . max ( 0 , Math . round (( Date . now () - Date . parse ( meta . generated )) / 60000 ));
el . refreshed . textContent = minutes < 60
? `index refreshed ${ minutes } minute ${ minutes === 1 ? '' : 's' } ago`
: `index refreshed ${ Math . round ( minutes / 60 ) } hour ${ Math . round ( minutes / 60 ) === 1 ? '' : 's' } ago` ;
2026-10-07 01:23:59 +02:00
document . querySelectorAll ( '.trend-controls .switch button' ). forEach ( button => { button . disabled = false ; });
2026-10-06 21:51:21 +02:00
paintHeroStats ();
render ();
}). catch ( err => console . warn ( err . message ));
2026-08-30 21:53:31 +02:00
// The repository metadata is optional: without it the PoC rows simply lose
// their star and age columns, which is the documented fallback.
2026-09-01 05:11:31 +02:00
loadJSON ( '/kev.json' , { cache : 'no-cache' }). then ( data => {
2026-08-31 00:13:01 +02:00
kev = data || {};
2026-09-01 19:01:07 +02:00
el . kevOnly . disabled = false ;
2026-09-01 04:46:30 +02:00
paintHeroStats ();
2026-10-06 21:51:21 +02:00
render ();
2026-09-01 19:01:07 +02:00
}). catch ( err => console . warn ( err . message ));
2026-09-04 02:32:22 +02:00
// The metadata comes in two halves. The CVSS rows are small and gate the
// severity filter, so they load with everything else; the advisories are
// 2.4 MB on the wire and only show inside a rendered row, so they follow
// the index rather than compete with it.
loadJSON ( '/cvss.json' , { cache : 'no-cache' }). then ( data => {
for ( const [ id , rows ] of Object . entries ( data || {})) ( metadata [ id ] || ( metadata [ id ] = {})). cvss = rows ;
2026-09-04 02:16:13 +02:00
severityEpoch += 1 ;
2026-09-04 01:49:25 +02:00
enableFilter ( 'severity' );
2026-09-01 19:01:07 +02:00
if ( state . ready && ( state . query || hasActiveFilters ())) render ();
2026-08-31 00:13:01 +02:00
}). catch ( err => console . warn ( err . message ));
2026-09-04 02:32:22 +02:00
const loadAdvisories = () => loadJSON ( '/advisories.json' , { cache : 'no-cache' }). then ( data => {
for ( const [ id , rows ] of Object . entries ( data || {})) ( metadata [ id ] || ( metadata [ id ] = {})). advisories = rows ;
if ( state . ready && ( state . query || hasActiveFilters ())) renderResults ( null );
}). catch ( err => console . warn ( err . message ));
2026-08-31 00:13:01 +02:00
2026-09-01 05:11:31 +02:00
loadJSON ( '/nuclei.json' , { cache : 'no-cache' }). then ( data => {
ratings = data || {};
2026-09-04 02:16:13 +02:00
severityEpoch += 1 ;
2026-09-04 01:49:25 +02:00
enableFilter ( 'severity' );
2026-09-01 19:01:07 +02:00
if ( state . ready && ( state . query || hasActiveFilters ())) render ();
2026-09-01 05:11:31 +02:00
}). catch ( err => console . warn ( err . message ));
2026-09-01 05:24:39 +02:00
loadJSON ( '/epss.json' , { cache : 'no-cache' }). then ( data => {
epss = data || {};
2026-10-06 21:09:05 +02:00
if ( state . ready && ( state . query || hasActiveFilters ())) render ();
2026-09-01 05:24:39 +02:00
}). catch ( err => console . warn ( err . message ));
2026-09-01 05:11:31 +02:00
loadJSON ( '/repo_meta.json' , { cache : 'no-cache' }). then ( data => {
2026-08-30 21:53:31 +02:00
repoMeta = data || {};
for ( const entry of dataset ) entry . _ranked = null ;
2026-09-01 19:01:07 +02:00
if ( state . ready && ( state . query || hasActiveFilters ())) renderResults ( null );
2026-08-30 21:53:31 +02:00
}). catch ( err => console . warn ( err . message ));
try {
2026-09-01 05:11:31 +02:00
dataset = prepareDataset ( await loadJSON ( '/CVE_list.json' , { cache : 'no-cache' }));
2026-08-30 21:53:31 +02:00
state . ready = true ;
2026-09-04 01:49:25 +02:00
enableFilter ( 'source' );
2026-08-30 21:53:31 +02:00
render ();
2026-09-04 02:16:13 +02:00
buildWordIndex ();
2026-08-30 21:53:31 +02:00
} catch ( err ) {
console . warn ( err . message );
2026-10-06 21:51:21 +02:00
state . loadFailed = true ;
render ();
2026-08-30 21:53:31 +02:00
}
2026-09-04 02:32:22 +02:00
loadAdvisories ();
2026-08-30 21:53:31 +02:00
})();