Files
cve/docs/logic.js
T
0xMarcio 79da26dd45 Stop searching the index on every keystroke
A pass over 104,043 entries costs 25-125ms, which is fast enough to feel
instant once but not nine times in a row while a word is typed. Coalesce a
burst at 160ms, skip single-character queries that match most of the index
without narrowing anything, and total the PoC count during the scan rather
than walking the matches again to display it.
2026-08-30 23:24:19 +02:00

572 lines
19 KiB
JavaScript

'use strict';
const REPLACE_STRINGS = ['HackTheBox - ', 'VulnHub - ', 'UHC - '];
const PAGE_SIZE = 50;
const POC_PREVIEW = 5;
const TREND_ROWS = 20;
const MIN_QUERY = 2;
const state = {
query: '',
mode: 'TRENDING',
shown: PAGE_SIZE,
descOpen: new Set(),
pocOpen: new Set(),
ready: false,
results: []
};
let dataset = [];
let repoMeta = {};
let trending = [];
let indexMeta = null;
/* ---- formatting -------------------------------------------------------- */
function escapeHTML(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
function formatCount(n) {
return n.toLocaleString('en-US');
}
function formatStars(n) {
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
}
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;
return (Date.now() - then) / 36e5;
}
/** Long form for the trending table: "3 hours ago", "4 months ago". */
function longAge(hours) {
if (hours == null) return '';
const units = [
[8760, 'year'], [720, 'month'], [168, 'week'], [24, 'day'], [1, 'hour']
];
for (const [size, name] of units) {
if (hours >= size) {
const n = Math.floor(hours / size);
return `${n} ${name}${n === 1 ? '' : 's'} ago`;
}
}
const minutes = Math.max(1, Math.round(hours * 60));
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
}
/** 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';
}
function repoFromUrl(url) {
const match = /^https?:\/\/(?:www\.)?github\.com\/([^/#?]+)\/([^/#?]+)/i.exec(url || '');
if (!match) return null;
return { owner: match[1], repo: match[2].replace(/\.git$/i, '') };
}
/** 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;
}
}
/* ---- matching ---------------------------------------------------------- */
function normalizeToSpaces(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
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]*'));
}
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
};
}
// 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) {
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;
}
// 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) {
if (index < 0) return 0;
if (index < 80) return 80;
if (index < 300) return 40;
return 0;
}
function pocText(entry) {
if (entry._pocText === undefined) {
entry._pocText = (entry.poc || []).join(' ').toLowerCase();
}
return entry._pocText;
}
function pocSpace(entry) {
if (entry._pocSpace === undefined) {
entry._pocSpace = normalizeToSpaces(pocText(entry));
}
return entry._pocSpace;
}
function descSpace(entry) {
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;
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;
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;
}
/** How many of an entry's links carry every search term. */
function countLinkHits(entry, matchers) {
let hits = 0;
for (const url of entry.poc || []) {
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;
}
function runSearch(query) {
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);
// 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);
const results = [];
results.pocTotal = 0;
for (const entry of dataset) {
let score = 0;
let matched = true;
let describes = false;
for (const matcher of matchers) {
const termScore = scoreEntry(entry, matcher);
if (termScore === 0) { matched = false; break; }
if (termScore >= 160) describes = true;
score += termScore;
}
if (!matched) continue;
if (negatives.some(m => scoreEntry(entry, m) > 0)) continue;
if (adjacent) {
const at = wordStart(entry._descText, adjacent);
if (at >= 0) { score += 300 + leadBonus(at); describes = true; }
else if (wordStart(pocText(entry), adjacent) >= 0) score += 100;
}
entry._score = score;
// A term nobody's advisory text uses — "log4shell" — leaves every match tied
// 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);
results.pocTotal += (entry.poc || []).length;
results.push(entry);
}
results.sort((a, b) => {
if (b._score !== a._score) return b._score - a._score;
if (b._hits !== a._hits) return b._hits - a._hits;
if (b._year !== a._year) return b._year - a._year;
return b._num - a._num;
});
return results;
}
function prepareDataset(raw) {
if (!Array.isArray(raw)) return [];
const out = [];
for (const entry of raw) {
const cve = (entry.cve || '').trim();
if (!cve || !Array.isArray(entry.poc) || entry.poc.length === 0) continue;
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;
out.push(entry);
}
return out;
}
/* ---- rendering --------------------------------------------------------- */
const el = {
input: document.querySelector('[data-search]'),
status: document.querySelector('[data-status]'),
results: document.querySelector('[data-results]'),
trending: document.querySelector('[data-trending]'),
trendTitle: document.querySelector('[data-trend-title]'),
trendNote: document.querySelector('[data-trend-note]'),
trendRows: document.querySelector('[data-trend-rows]'),
refreshed: document.querySelector('[data-refreshed]')
};
function pocRow(url) {
const parsed = repoFromUrl(url);
const href = escapeHTML(url);
if (!parsed) {
return `<div class="poc-row"><span class="poc-name">` +
`<a href="${href}" target="_blank" rel="noopener">${escapeHTML(plainLinkLabel(url))}</a>` +
`</span><span class="poc-stars"></span><span class="poc-age"></span></div>`;
}
const key = (parsed.owner + '/' + parsed.repo).toLowerCase();
const meta = repoMeta[key];
const stars = meta ? meta[0] : null;
const hours = meta ? hoursSince(meta[1]) : null;
const starClass = stars != null && stars >= 500 ? 'poc-stars is-popular' : 'poc-stars';
return `<div class="poc-row">` +
`<span class="poc-name"><span class="poc-owner">${escapeHTML(parsed.owner)}</span>` +
`<span class="poc-sep"> / </span>` +
`<a href="${href}" target="_blank" rel="noopener">${escapeHTML(parsed.repo)}</a></span>` +
`<span class="${starClass}">${stars == null ? '' : formatStars(stars) + ' ★'}</span>` +
`<span class="poc-age">${escapeHTML(shortAge(hours))}</span></div>`;
}
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
* their original order behind both — a reference has no repository to rank. */
function rankedLinks(entry) {
if (entry._ranked) return entry._ranked;
const id = compact(entry.cve);
const scored = (entry.poc || []).map((url, index) => {
const parsed = repoFromUrl(url);
const meta = parsed && repoMeta[(parsed.owner + '/' + parsed.repo).toLowerCase()];
return {
url,
index,
dedicated: compact(url).includes(id) ? 1 : 0,
stars: meta ? meta[0] : -1
};
});
scored.sort((a, b) =>
(b.dedicated - a.dedicated) || (b.stars - a.stars) || (a.index - b.index));
entry._ranked = scored.map(item => item.url);
return entry._ranked;
}
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);
const more = all
? '↑ show fewer'
: (links.length > POC_PREVIEW
? `+ ${formatCount(links.length - POC_PREVIEW)} more repositories`
: 'all repositories shown');
const moreButton = links.length > 1
? `<button type="button" class="poc-more" data-toggle-poc="${escapeHTML(id)}">${more}</button>`
: '';
const dates = [];
if (entry.published) dates.push(['published', entry.published]);
if (entry.modified) dates.push(['modified', entry.modified]);
const dateHtml = dates.length
? `<dl class="result-dates">${dates.map(([label, value]) =>
`<div class="result-date"><dt>${label}</dt><dd>${escapeHTML(value)}</dd></div>`).join('')}</dl>`
: '';
return `<div class="result-row">
<div class="result-meta">
<a class="result-id" href="https://nvd.nist.gov/vuln/detail/${encodeURIComponent(id)}" target="_blank" rel="noopener">${escapeHTML(id)}</a>
<div class="result-pocs">${formatCount(links.length)} linked PoC${links.length === 1 ? '' : 's'}</div>
${dateHtml}
<a class="mitre" href="https://www.cve.org/CVERecord?id=${encodeURIComponent(id)}" target="_blank" rel="noopener">MITRE ↗</a>
</div>
<div class="result-body">
<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">
${visible.map(pocRow).join('')}
${moreButton}
</div>
</div>
</div>`;
}
/** 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;
}
}
}
function renderResults(elapsed) {
const results = state.results;
el.trending.hidden = true;
el.results.hidden = false;
if (!results.length) {
el.results.innerHTML = `<div class="empty">
<div class="empty-head">No CVE matched <b>${escapeHTML(state.query)}</b></div>
<div class="empty-hint">Try a CVE ID, a vendor, or a product name. Prefix a term with <code>-</code> to exclude it.</div>
</div>`;
return;
}
const shown = results.slice(0, state.shown);
const pocTotal = results.pocTotal;
const remaining = results.length - shown.length;
const footer = remaining > 0
? `<button type="button" class="poc-more" data-more-results>+ ${formatCount(remaining)} more matching CVEs</button>`
: '';
el.results.innerHTML = `<div class="card-head">
<h2>Results</h2>
<span class="card-count">${formatCount(results.length)} CVE${results.length === 1 ? '' : 's'} · ${formatCount(pocTotal)} PoCs</span>
</div>
<div class="col-head"><span>CVE</span><span>DESCRIPTION / POC LINKS</span></div>
${shown.map(resultRow).join('')}${footer}`;
pruneExpanders();
if (elapsed != null) {
el.status.textContent = `matched in ${Math.max(1, Math.round(elapsed))}ms`;
}
}
function trendRow(item) {
const popular = item.stars >= 500 ? ' is-popular' : '';
return `<div class="trend-row">
<span class="trend-stars${popular}">${formatStars(item.stars)} ★</span>
<span class="trend-age">${escapeHTML(longAge(item._pushed))}</span>
<a class="trend-name" href="${escapeHTML(item.url)}" target="_blank" rel="noopener">${escapeHTML(item.name)}</a>
<span class="trend-desc">${escapeHTML(item.desc || '')}</span>
</div>`;
}
function renderTrending() {
el.results.hidden = true;
el.trending.hidden = false;
const ranked = trending.slice();
if (state.mode === 'TRENDING') {
// Stars weighted against the age the row displays, so the order is legible
// from the table itself: a fresh PoC pulling stars outranks a bigger one
// that has been sitting still.
const rank = r => r.stars / Math.pow((r._pushed == null ? 8760 : r._pushed) + 6, 0.45);
ranked.sort((a, b) => rank(b) - rank(a));
} else {
ranked.sort((a, b) => (a._pushed == null ? Infinity : a._pushed) - (b._pushed == null ? Infinity : b._pushed));
}
const rows = ranked.slice(0, TREND_ROWS);
el.trendTitle.textContent = state.mode === 'TRENDING'
? 'Trending Proof-of-Concepts'
: 'Recently updated Proof-of-Concepts';
el.trendNote.textContent = state.mode === 'TRENDING'
? 'stars weighted against time since the last commit'
: `newest commit first · ${formatCount(trending.length)} repositories`;
el.trendRows.innerHTML = rows.length
? rows.map(trendRow).join('')
: '<div class="trend-row"><span class="trend-desc">No recent PoCs.</span></div>';
document.querySelectorAll('.toggle button').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode));
});
}
function idleStatus() {
if (!indexMeta) return '';
return `${formatCount(indexMeta.total_cves)} CVEs · ${formatCount(indexMeta.with_pocs)} with PoCs`;
}
function render() {
if (state.query.length < MIN_QUERY) {
el.status.textContent = state.ready ? idleStatus() : 'loading index…';
renderTrending();
return;
}
if (!state.ready) {
el.results.hidden = false;
el.trending.hidden = true;
el.status.textContent = 'loading index…';
el.results.innerHTML = `<div class="empty">
<div class="empty-head">Loading the CVE index…</div>
<div class="empty-hint">The full index is a single download; results appear as soon as it lands.</div>
</div>`;
return;
}
const started = performance.now();
state.results = runSearch(state.query);
renderResults(performance.now() - started);
}
/* ---- wiring ------------------------------------------------------------ */
// 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;
el.input.addEventListener('input', () => {
const next = el.input.value.trim();
if (next === state.query) return;
state.query = next;
state.shown = PAGE_SIZE;
clearTimeout(searchTimer);
if (next.length < MIN_QUERY) {
render();
return;
}
searchTimer = setTimeout(render, 160);
});
document.querySelector('.search').addEventListener('submit', event => event.preventDefault());
document.querySelector('.toggle').addEventListener('click', event => {
const button = event.target.closest('button[data-mode]');
if (!button) return;
state.mode = button.dataset.mode;
renderTrending();
});
el.results.addEventListener('click', event => {
const desc = event.target.closest('[data-toggle-desc]');
const poc = event.target.closest('[data-toggle-poc]');
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);
} else if (more) {
state.shown += PAGE_SIZE;
} else {
return;
}
renderResults(null);
});
async function loadJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${url} (${response.status})`);
return response.json();
}
(async () => {
try {
const meta = await loadJSON('/trending_poc.json', { cache: 'no-store' });
indexMeta = meta;
trending = (meta.items || []).map(item => Object.assign({}, item, {
_pushed: hoursSince(item.pushed),
_created: hoursSince(item.created)
}));
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`;
} catch (err) {
console.warn(err.message);
}
render();
// The repository metadata is optional: without it the PoC rows simply lose
// their star and age columns, which is the documented fallback.
loadJSON('/repo_meta.json').then(data => {
repoMeta = data || {};
for (const entry of dataset) entry._ranked = null;
if (state.query && state.ready) renderResults(null);
}).catch(err => console.warn(err.message));
try {
dataset = prepareDataset(await loadJSON('/CVE_list.json'));
state.ready = true;
render();
} catch (err) {
console.warn(err.message);
el.status.textContent = 'index unavailable';
}
})();