diff --git a/docs/logic.js b/docs/logic.js index 67783cf5a6d..b52149b7f23 100644 --- a/docs/logic.js +++ b/docs/logic.js @@ -81,10 +81,47 @@ function hoursSince(iso) { * 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 calendar = Date.parse(value.slice(0, 10) + 'T00:00:00Z'); + if (Number.isNaN(calendar) || new Date(calendar).toISOString().slice(0, 10) !== value.slice(0, 10)) return ''; const time = Date.parse(/T[\d:.]+$/.test(value) ? value + 'Z' : value); return Number.isNaN(time) ? '' : new Date(time).toISOString().slice(0, 10); } +/** Keep day-only sources honest: they cannot tell us how many hours ago. */ +function trendAge(value, now = Date.now()) { + if (!utcDay(value)) return 'N/A'; + const dayOnly = value.length === 10; + const time = Date.parse(/T[\d:.]+$/.test(value) ? value + 'Z' : value); + const age = Math.max(0, now - time); + let count, unit, label; + if (dayOnly) { + count = Math.max(0, Math.floor(now / 864e5) - Math.floor(time / 864e5)); + unit = 'day'; + if (!count) label = 'today'; + } else if (age < 6e4) label = 'just now'; + else if (age < 36e5) { count = Math.floor(age / 6e4); unit = 'minute'; } + else if (age < 864e5) { count = Math.floor(age / 36e5); unit = 'hour'; } + else { count = Math.floor(age / 864e5); unit = 'day'; } + label ||= `${formatCount(count)} ${unit}${count === 1 ? '' : 's'} ago`; + const iso = dayOnly ? value : new Date(time).toISOString(); + const title = dayOnly ? `${value} UTC (time unavailable)` : iso.replace('T', ' ').replace('Z', ' UTC'); + return ``; +} + +function refreshTrendAges() { + if (document.hidden) return; + for (const cell of document.querySelectorAll('time.trend-age')) { + cell.outerHTML = trendAge(cell.getAttribute('datetime')); + } +} + +function tickTrendAges() { + refreshTrendAges(); + setTimeout(tickTrendAges, 60000); +} +setTimeout(tickTrendAges, 60000); +document.addEventListener('visibilitychange', refreshTrendAges); + /** Compact age for the PoC columns: 3h, 3d, 2w, 5mo, 2y. */ function shortAge(hours) { if (hours == null) return ''; @@ -128,7 +165,9 @@ 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]*')); + // Identifier separators may vary; unrelated prose must not join up into a + // name. In particular, "php, (2) show" is not a match for "php2sh". + return new RegExp('(?:^|[^a-z0-9])' + escaped.split('').join('[-_.]*')); } function buildMatcher(term) { @@ -138,6 +177,7 @@ function buildMatcher(term) { const normalized = raw.replace(/[^a-z0-9]+/g, ''); return { raw, + compact: normalized, isPhrase, phrase: isPhrase ? normalizeToSpaces(raw) : '', loose: !isPhrase && normalized.length >= 4 ? buildLooseRegex(raw) : null @@ -197,31 +237,62 @@ function descSpace(entry) { return entry._descSpace; } +function identityText(entry) { + if (entry._identityText === undefined) { + entry._identityText = entryLinks(entry).map(url => { + const repo = repoFromUrl(url); + if (!repo) return ''; + // Repository and artifact names identify the PoC; its owner and host + // and GitHub's routing/ref segments stay in the lower URL tier. + const path = url.replace(/^https?:\/\/(?:www\.)?github\.com\/[^/]+\/[^/?#]+/i, '').split(/[?#]/)[0]; + const artifact = /^\/(?:blob|tree)\/[^/]+\/(.+)/i.exec(path); + return repo.repo + (artifact ? '/' + artifact[1] : ''); + }).join('\n').toLowerCase(); + } + return entry._identityText; +} + +function identitySpace(entry) { + if (entry._identitySpace === undefined) { + // A quoted phrase cannot run from the end of one repository name into the next. + entry._identitySpace = identityText(entry).split('\n').map(normalizeToSpaces).join('\n'); + } + return entry._identitySpace; +} + +function matchAt(text, matcher) { + const at = wordStart(matcher.isPhrase ? normalizeToSpaces(text) : text, + matcher.isPhrase ? matcher.phrase : matcher.raw); + if (at >= 0 || !matcher.loose) return at; + const loose = matcher.loose.exec(text); + return loose ? loose.index : -1; +} + function scoreEntry(entry, matcher) { if (!matcher || !matcher.raw) return 0; if (matcher.isPhrase) { const phrase = matcher.phrase; if (!phrase) return 0; - if (entry._titleSpace && wordStart(entry._titleSpace, phrase) >= 0) return 300; + if (entry._titleSpace && wordStart(entry._titleSpace, phrase) >= 0) return 400; + if (wordStart(identitySpace(entry), phrase) >= 0) return 350; const at = wordStart(descSpace(entry), phrase); - if (at >= 0) return 200 + leadBonus(at); + if (at >= 0) return 240 + leadBonus(at); if (wordStart(pocSpace(entry), phrase) >= 0) return 80; return 0; } const raw = matcher.raw; - if (entry._cveText.includes(raw)) return 600; + if (entry._cveCompact === matcher.compact) return 1000; + if (wordStart(entry._cveText, raw) >= 0 || + (matcher.loose && entry._cveCompact.startsWith(matcher.compact))) return 600; // 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; - const at = wordStart(entry._descText, raw); + if (entry._titleText && matchAt(entry._titleText, matcher) >= 0) return 400; + if (matchAt(identityText(entry), matcher) >= 0) return 350; + const at = matchAt(entry._descText, matcher); 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; - } + if (matchAt(pocText(entry), matcher) >= 0) return 80; return 0; } @@ -230,7 +301,7 @@ function countLinkHits(entry, matchers) { let hits = 0; for (const url of entryLinks(entry)) { const text = url.toLowerCase(); - if (matchers.every(m => wordStart(text, m.raw) >= 0 || (m.loose && m.loose.test(text)))) { + if (matchers.every(m => matchAt(text, m) >= 0)) { hits += 1; if (hits >= 999) break; } @@ -329,13 +400,24 @@ function tokensOf(text) { return text ? text.match(/[a-z0-9]+/g) || [] : []; } +function indexWords(text) { + const words = tokensOf(text); + // Every suffix is needed: "php-2-shell" inside "lab-php-2-shell-poc" + // must be a candidate for "php2sh", just as it is in the scoring pass. + for (const compound of text.match(/[a-z0-9]+(?:[-_.]+[a-z0-9]+)+/g) || []) { + const parts = tokensOf(compound); + for (let i = 0; i < parts.length - 1; i++) words.push(parts.slice(i).join('')); + } + return words; +} + 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); + const words = indexWords(entry._cveText + ' ' + entry._descText + ' ' + entry._titleText + ' ' + pocText(entry)); 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. @@ -437,10 +519,14 @@ function candidateCounts(groups) { for (const at of wordIndex.postings.get(part) || []) bump(at); } }); - // A CVE id is not a word in anybody's description, so it is checked apart. - const raw = matcher.raw; + if (matcher.loose && matcher.compact !== matcher.raw) { + const [start, end] = wordRange(matcher.compact); + for (let i = start; i < end; i++) { + for (const at of wordIndex.postings.get(wordIndex.words[i])) hit[at] = 1; + } + } for (let at = 0; at < size; at++) { - if (partCount[at] === parts.length || dataset[at]._cveText.includes(raw)) hit[at] = 1; + if (partCount[at] === parts.length) hit[at] = 1; } } for (let at = 0; at < size; at++) if (hit[at]) total[at] += 1; @@ -588,6 +674,8 @@ function comparePocs(a, b) { } function runSearch(query, scan = false, corrected = false) { + // Pasted CVE identifiers also commonly arrive without hyphens or with spaces. + query = query.trim().replace(/^cve[\s_-]*(\d{4})[\s_-]*(\d{4,})$/i, 'CVE-$1-$2'); const terms = query.match(/-?"[^"]+"|-?\S+/g) || []; const cleaned = terms.map(t => t.replace(/^(-?)"/, '$1').replace(/"$/, '')); const positive = cleaned.filter(t => t && t[0] !== '-'); @@ -597,6 +685,10 @@ function runSearch(query, scan = false, corrected = false) { // 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 adjacentSpace = normalizeToSpaces(adjacent); + const compound = positive.length > 1 && positive.length <= 4 && + positive.every(term => /^[a-z0-9]+$/i.test(term)) && !terms.some(term => term[0] !== '-' && term.includes('"')) + ? buildMatcher(positive.join('')) : null; const negatives = cleaned .filter(t => t && t[0] === '-') .map(t => t.slice(1)) @@ -605,43 +697,49 @@ function runSearch(query, scan = false, corrected = false) { .filter(Boolean); const counts = !scan && wordIndex.ready && groups.length ? candidateCounts(groups) : null; + const compoundCounts = counts && compound ? candidateCounts([[compound]]) : null; const results = []; results.pocTotal = 0; for (let at = 0; at < dataset.length; at++) { - if (counts && counts[at] !== groups.length) continue; + if (counts && counts[at] !== groups.length && !compoundCounts?.[at]) continue; const entry = dataset[at]; if (!matchesFilters(entry)) continue; let score = 0; let matched = true; - let describes = false; + let linked = false; for (const group of groups) { const termScore = scoreGroup(entry, group); if (termScore === 0) { matched = false; break; } - if (termScore >= 160) describes = true; + if (termScore === 350 || termScore === 80) linked = true; score += termScore; } + const compoundScore = compound ? scoreEntry(entry, compound) : 0; + if (compoundScore >= 350 && (!matched || compoundScore * groups.length > score)) { + score = compoundScore * groups.length; + matched = true; + linked = compoundScore === 350; + } 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; + if (wordStart(identitySpace(entry), adjacentSpace) >= 0) score += 400; + else { + const at = wordStart(descSpace(entry), adjacentSpace); + if (at >= 0) score += 300 + leadBonus(at); + else if (wordStart(pocSpace(entry), adjacentSpace) >= 0) score += 100; + } + if (compoundScore >= 350) score += 400; } entry._score = score; - // A term nobody's advisory text uses, "log4shell" for one, 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); + // Within the same match tier, several PoCs using a name are stronger + // evidence than a single link mentioning it on an unrelated, newer CVE. + entry._hits = linked ? countLinkHits(entry, compoundScore >= 350 ? [compound] : matchers) : 0; results.pocTotal += entryLinks(entry).length; results.push(entry); } - // 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. - let out = results; - if (counts && results.length < 5 && matchers.some(m => m.loose)) out = runSearch(query, true, corrected); + const out = results; if (!out.length && !scan && !corrected && wordIndex.ready) { const fix = correctTerms(terms); @@ -652,7 +750,7 @@ function runSearch(query, scan = false, corrected = false) { } } - const star = scan || corrected ? null : namedCve(query); + const star = namedCve(query); if (star) { let entry = out.find(e => e.cve === star); if (!entry) { @@ -683,6 +781,7 @@ function prepareDataset(raw) { if (!entryLinks(entry).length) continue; const parts = cve.split('-'); entry._cveText = cve.toLowerCase(); + entry._cveCompact = entry._cveText.replace(/-/g, ''); entry._descText = REPLACE_STRINGS .reduce((desc, str) => desc.replace(str, ''), entry.desc || '') .toLowerCase(); @@ -1049,7 +1148,7 @@ function trendRow(item, landed) { ? `${escapeHTML(item.cve)} details` : ''; return `
${starCell}` + - `${utcDay(landed ? item.released : item.pushed) || 'N/A'}` + + trendAge(landed ? item.released : item.pushed) + `${flagged}${escapeHTML(name)}${detail}` + `${escapeHTML(item.desc || 'Description unavailable')}
`; } diff --git a/docs/style.css b/docs/style.css index 81a38ffe9e0..1ff64428c3e 100644 --- a/docs/style.css +++ b/docs/style.css @@ -384,7 +384,7 @@ a.chip:hover { color: var(--fg); border-color: var(--fg-muted); } .switch-sort button { padding: 5px 9px; font-size: 10px; } .trending .col-head, .trend-row { - grid-template-columns: 74px 96px 300px minmax(0, 1fr); + grid-template-columns: 74px 112px 300px minmax(0, 1fr); gap: 18px; } .trend-row { @@ -466,12 +466,13 @@ a.chip:hover { color: var(--fg); border-color: var(--fg-muted); } .results .col-head { display: none; } .result-row { grid-template-columns: 1fr; } .result-meta { border-right: 0; border-bottom: 1px solid var(--border-muted); } - .trending .col-head, .trend-row { grid-template-columns: 70px 92px minmax(0, 1fr); } + .trending .col-head, .trend-row { grid-template-columns: 70px 112px minmax(0, 1fr); } .col-desc, .trend-desc { display: none; } } @media (max-width: 620px) { - .trending .col-head, .trend-row { grid-template-columns: 56px 80px minmax(0, 1fr); gap: 12px; } + .trending .col-head, .trend-row { grid-template-columns: 56px 96px minmax(0, 1fr); gap: 12px; } + .trend-age { white-space: normal; line-height: 1.4; } .trend-name-cell { white-space: normal; overflow-wrap: anywhere; } /* Artifact names wrap while repository stars stay visible on small screens. */ .poc-row:has(.plain) { grid-template-columns: minmax(0, 1fr); } diff --git a/scripts/build_pages.py b/scripts/build_pages.py index 91c857ca105..00aca71b57d 100644 --- a/scripts/build_pages.py +++ b/scripts/build_pages.py @@ -708,9 +708,38 @@ def star_label(stars: int) -> str: return f"{Decimal(stars / 1000).quantize(Decimal('0.1'), ROUND_HALF_UP)}k" -def trend_rows(items: object, landed: bool) -> str: +def trend_age(value: object, now: datetime) -> str: + """Relative age and exact UTC tooltip, matching logic.js's trendAge.""" + if not utc_day(value): + return 'N/A' + moment = datetime.fromisoformat(value.replace("Z", "+00:00")) + moment = moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc) + day_only = len(value) == 10 + seconds = max(0, (now - moment).total_seconds()) + label = "" + if day_only: + count, unit = max(0, (now.astimezone(timezone.utc).date() - moment.date()).days), "day" + if not count: + label = "today" + elif seconds < 60: + label = "just now" + elif seconds < 3600: + count, unit = int(seconds // 60), "minute" + elif seconds < 86400: + count, unit = int(seconds // 3600), "hour" + else: + count, unit = int(seconds // 86400), "day" + if not label: + label = f"{count:,} {unit}{'' if count == 1 else 's'} ago" + iso = value if day_only else moment.isoformat(timespec="milliseconds").replace("+00:00", "Z") + title = f"{value} UTC (time unavailable)" if day_only else iso.replace("T", " ").replace("Z", " UTC") + return f'' + + +def trend_rows(items: object, landed: bool, now: datetime | None = None) -> str: """A front-page list as logic.js renders it, in feed order. Just landed is dated by first release, Trending by latest artifact commit.""" + now = now or datetime.now(timezone.utc) rows = [] for item in (items if isinstance(items, list) else [])[:TREND_ROWS]: repository = repo_of(str(item.get("url") or "")) @@ -729,7 +758,7 @@ def trend_rows(items: object, landed: bool) -> str: if item.get("page") else "") rows.append( f'
{star_cell}' - f'{utc_day(item.get("released" if landed else "pushed")) or "N/A"}' + f'{trend_age(item.get("released" if landed else "pushed"), now)}' f'{flagged}{esc(name)}{detail}' f'{esc(item.get("desc") or "Description unavailable")}
' @@ -737,7 +766,7 @@ def trend_rows(items: object, landed: bool) -> str: return "".join(rows) or '
No recent PoCs.
' -def homepage(cves: list, kev: dict, trending: dict) -> str: +def homepage(cves: list, kev: dict, trending: dict, now: datetime | None = None) -> str: """Publish the same content before JavaScript or a crawler render runs.""" indexed = {entry["cve"] for entry in cves} if trending["with_pocs"] != len(indexed) or trending["total_cves"] < len(indexed): @@ -763,7 +792,7 @@ def homepage(cves: list, kev: dict, trending: dict) -> str: stylesheet_url=asset_url("style.css"), script_url=asset_url("logic.js"), sources_line=SOURCES_LINE, project_links=project_links(), website_schema=json_ld(website), total_cves=f"{trending['total_cves']:,}", with_pocs=f"{len(indexed):,}", kev=f"{len(kev):,}", - landed_rows=trend_rows(trending.get("landed"), True), + landed_rows=trend_rows(trending.get("landed"), True, now), generated=esc(generated.strftime("%Y-%m-%d %H:%M UTC")), year_links=" ".join(f'{esc(year)}' for year in years), ) diff --git a/tests/test_homepage_lists.py b/tests/test_homepage_lists.py index 0e494bb6566..db7cfac33ee 100644 --- a/tests/test_homepage_lists.py +++ b/tests/test_homepage_lists.py @@ -13,6 +13,7 @@ import shutil import subprocess import sys import unittest +from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -47,6 +48,7 @@ TRENDING = [ ] FEED = {"generated": "2026-10-07T09:00:00+00:00", "total_cves": 5, "with_pocs": 4, "items": TRENDING, "landed": LANDED, "ranking": "stars_weighted_by_artifact_release_age"} +NOW = datetime.fromisoformat(FEED["generated"]) INDEXED = [{"cve": cve} for cve in ("CVE-2024-26642", "CVE-2026-21589", "CVE-2021-44228", "CVE-2026-24061")] HARNESS = r""" @@ -70,6 +72,7 @@ const document = { } }; const context = vm.createContext({ + Date: class extends Date { static now() { return Date.parse(input.now); } }, console: { warn() {} }, document, performance, MessageChannel, URLSearchParams, URL, location: { pathname: '/', search: '' }, window: { matchMedia: () => ({ matches: false }) }, @@ -102,7 +105,7 @@ def browser(feed: dict) -> dict: node = shutil.which("node") if node is None: raise unittest.SkipTest("node renders the browser lists") - payload = {"script": str(ROOT / "docs" / "logic.js"), "feed": feed} + payload = {"script": str(ROOT / "docs" / "logic.js"), "feed": feed, "now": NOW.isoformat()} done = subprocess.run([node, "-e", HARNESS], input=json.dumps(payload), capture_output=True, text=True, timeout=60) if done.returncode: @@ -118,11 +121,11 @@ def as_browser(markup: str) -> str: def cells(markup: str) -> list[dict]: rows = [] for row in re.findall(r'
(.*?)
', markup): - stars = re.search(r']*>(.*?)', row).group(1) + stars = re.search(r']*>(.*?)<(?:span|time) class="trend-age"', row).group(1) href, name = re.search(r']*>([^<]*)', row).groups() rows.append({"href": html.unescape(href), "name": html.unescape(name), "stars": re.sub(r"<[^>]+>", "", stars), "kev": 'class="trend-kev"' in row, - "date": re.search(r'([^<]*)', row).group(1)}) + "date": re.search(r'<(?:span|time) class="trend-age"[^>]*>([^<]*)', row).group(1)}) return rows @@ -132,12 +135,12 @@ class HomepageListTests(unittest.TestCase): cls.views = browser(FEED) def test_browser_and_prerendered_homepage_render_the_same_rows(self) -> None: - landed, trending = build_pages.trend_rows(LANDED, True), build_pages.trend_rows(TRENDING, False) + landed, trending = build_pages.trend_rows(LANDED, True, NOW), build_pages.trend_rows(TRENDING, False, NOW) for mode in ("initial", "LANDED", "RECENT"): with self.subTest(mode=mode): self.assertEqual(self.views[mode]["rows"], as_browser(landed)) self.assertEqual(self.views["TRENDING"]["rows"], as_browser(trending)) - page = build_pages.homepage(INDEXED, {}, FEED) + page = build_pages.homepage(INDEXED, {}, FEED, NOW) self.assertIn(landed, page) self.assertNotIn("telnetd", page) @@ -148,13 +151,25 @@ class HomepageListTests(unittest.TestCase): self.assertIn(SHARED, [row["href"] for row in landed]) self.assertIn(SHARED, [row["href"] for row in trending]) - def test_dates_are_utc_days_and_unknown_dates_are_explicit(self) -> None: + def test_relative_dates_preserve_release_update_and_timestamp_precision(self) -> None: landed, trending = cells(self.views["LANDED"]["rows"]), cells(self.views["TRENDING"]["rows"]) - self.assertEqual([row["date"] for row in landed], ["2026-10-07", "2026-10-06", "2026-10-05"]) - self.assertEqual([row["date"] for row in trending], ["2026-10-06", "2026-10-03", "N/A"]) + self.assertEqual([row["date"] for row in landed], ["7 hours ago", "17 hours ago", "2 days ago"]) + self.assertEqual([row["date"] for row in trending], ["17 hours ago", "3 days ago", "N/A"]) self.assertEqual([self.views[mode]["date"] for mode in ("initial", "TRENDING", "RECENT")], ["RELEASED", "UPDATED", "RELEASED"]) - self.assertNotIn(" ago", self.views["LANDED"]["rows"] + self.views["TRENDING"]["rows"]) + self.assertIn('title="2026-10-07 01:30:00.000 UTC"', self.views["LANDED"]["rows"]) + self.assertIn('title="2026-10-05 UTC (time unavailable)"', self.views["LANDED"]["rows"]) + + def test_relative_age_boundaries_match_in_browser_and_prerender(self) -> None: + cases = [(None, "N/A"), ("invalid", "N/A"), ("2026-02-30", "N/A"), ("2026-10-08T00:00:00Z", "just now"), + ("2026-10-07T08:59:01Z", "just now"), ("2026-10-07T08:59:00Z", "1 minute ago"), + ("2026-10-07T08:01:00", "59 minutes ago"), ("2026-10-07T08:00:00Z", "1 hour ago"), + ("2026-10-06T09:00:00Z", "1 day ago"), ("2026-10-07", "today"), + ("2026-10-06", "1 day ago")] + items = [{**LANDED[0], "released": value} for value, _ in cases] + markup = browser({**FEED, "landed": items})["initial"]["rows"] + self.assertEqual(markup, as_browser(build_pages.trend_rows(items, True, NOW))) + self.assertEqual([row["date"] for row in cells(markup)], [label for _, label in cases]) def test_artifacts_show_host_repository_stars_and_keep_their_variant_or_host(self) -> None: landed = cells(self.views["LANDED"]["rows"]) diff --git a/tests/test_search.js b/tests/test_search.js index 40cc3316949..b65e5548b53 100644 --- a/tests/test_search.js +++ b/tests/test_search.js @@ -46,6 +46,106 @@ function startPage(t, { search = '', trending, kev = {}, repositories = {}, inde return { elements, calls, context }; } +async function searchFixture(t, entries, options = {}) { + const { context } = startPage(t, { entries, trending: new Promise(() => {}), ...options }); + while (!vm.runInContext('wordIndex.ready', context)) await new Promise(setImmediate); + return (query, scan = false) => { + context.query = query; + return Array.from(vm.runInContext(`runSearch(query, ${scan})`, context), row => row.cve); + }; +} + +test('repository name prefixes beat incidental prose without joining numbered filenames', async t => { + const search = await searchFixture(t, [ + { cve: 'CVE-2004-0660', desc: 'XSS in (1) show_archives.php, (2) show_news.php', edb: ['https://www.exploit-db.com/exploits/24238'] }, + { cve: 'CVE-2026-109618', desc: 'RCE via OOM corruption', poc: ['https://github.com/researcher/php2shell-poc'] }, + { cve: 'CVE-2026-1000', desc: 'A report mentions php2shell in passing.', poc: ['https://github.com/researcher/unrelated'] }, + { cve: 'CVE-2026-2000', desc: 'Unrelated', poc: ['https://github.com/myphp2shell/unrelated'] } + ]); + for (const query of ['php2sh', 'php2shell']) { + assert.deepEqual(search(query), ['CVE-2026-109618', 'CVE-2026-1000']); + assert.deepEqual(search(query), search(query, true)); + } +}); + +test('separator variants have complete indexed results even when more than five records match', async t => { + const entries = Array.from({ length: 8 }, (_, i) => ({ cve: `CVE-2026-${1000 + i}`, + desc: `${i < 6 ? 'PANOS' : 'PAN-OS'} vulnerability`, poc: [`https://github.com/owner/poc-${i}`] })); + entries.push({ cve: 'CVE-2026-2000', desc: 'Pending details', poc: ['https://github.com/owner/lab-pan_os-poc'] }); + const search = await searchFixture(t, entries); + for (const query of ['panos', 'pan-os', 'pan_os', 'pan.os']) { + assert.equal(search(query).length, 9); + assert.deepEqual(search(query), search(query, true)); + assert.deepEqual(search(query), search('panos')); + } +}); + +test('an exact CVE beats a longer numeric prefix and supports pasted identifier spellings', async t => { + const entries = ['CVE-2026-1234', 'CVE-2026-12345', 'CVE-2026-9876'].map(cve => ({ + cve, desc: 'Related issue CVE-2026-1234', poc: ['https://github.com/owner/CVE-2026-1234'] + })); + const search = await searchFixture(t, entries, { kev: { 'CVE-2026-12345': ['2026-10-21', false] } }); + for (const query of ['CVE-2026-1234', 'cve20261234', 'CVE 2026 1234', 'cve_2026_1234']) { + assert.equal(search(query)[0], 'CVE-2026-1234'); + assert.deepEqual(search(query), search(query, true)); + } +}); + +test('multiword identifier names outrank scattered terms and respect exclusions', async t => { + const search = await searchFixture(t, [ + { cve: 'CVE-2026-1000', desc: 'PHP 2 allows users to execute shell commands', poc: ['https://github.com/owner/unrelated'] }, + { cve: 'CVE-2026-109618', desc: 'RCE via OOM corruption', poc: ['https://github.com/owner/php2shell-poc'] }, + { cve: 'CVE-2026-109619', desc: 'RCE via stale global state', poc: ['https://github.com/owner/lab-php-2-shell'] } + ]); + assert.deepEqual(new Set(search('php 2 shell').slice(0, 2)), new Set(['CVE-2026-109618', 'CVE-2026-109619'])); + assert.deepEqual(search('php 2 shell'), search('php 2 shell', true)); + assert.deepEqual(search('php 2 shell -oom'), ['CVE-2026-109619', 'CVE-2026-1000']); + assert.deepEqual(search('php 2 shell -"stale global"'), ['CVE-2026-109618', 'CVE-2026-1000']); + assert.deepEqual(search('"php 2 shell"'), ['CVE-2026-109619']); +}); + +test('repository and file names outrank matching owners and GitHub routing segments', async t => { + const search = await searchFixture(t, [ + { cve: 'CVE-2026-1000', desc: 'Pending details', poc: ['https://github.com/owner/project/blob/main/issue.py'] }, + { cve: 'CVE-2026-2000', desc: 'An issue in the main component', poc: ['https://github.com/owner/unrelated'] }, + { cve: 'CVE-2026-3000', desc: 'Pending details', poc: ['https://github.com/owner/main-poc'] }, + { cve: 'CVE-2026-4000', desc: 'Pending details', poc: ['https://github.com/owner/project/blob/ref/main.py'] }, + { cve: 'CVE-2026-5000', desc: 'Pending details', poc: ['https://github.com/main/unrelated'] } + ]); + assert.deepEqual(new Set(search('main').slice(0, 2)), new Set(['CVE-2026-3000', 'CVE-2026-4000'])); + assert.equal(search('main')[2], 'CVE-2026-2000'); + assert.deepEqual(search('main'), search('main', true)); +}); + +test('aliases, phrases, negatives, named vulnerabilities and typo correction remain usable', async t => { + const search = await searchFixture(t, [ + { cve: 'CVE-2026-1000', desc: 'WordPress SQL injection', poc: ['https://github.com/owner/wp-poc'] }, + { cve: 'CVE-2026-2000', desc: 'Widget SQL injection', poc: ['https://github.com/owner/widget-poc'] }, + { cve: 'CVE-2026-3000', desc: 'SQL database error with command injection', poc: ['https://github.com/owner/mixed'] }, + { cve: 'CVE-2021-44228', desc: 'Apache Log4j remote code execution', poc: ['https://github.com/owner/log4j-poc'] } + ]); + assert.deepEqual(search('"sql injection" -wordpress'), ['CVE-2026-2000']); + assert.deepEqual(new Set(search('sqli')), new Set(['CVE-2026-1000', 'CVE-2026-2000'])); + assert.deepEqual(search('rce'), ['CVE-2021-44228']); + assert.deepEqual(search('wordprss'), ['CVE-2026-1000']); + for (const query of ['"sql injection" -wordpress', 'sqli', 'rce', 'log4shell', 'log-4j']) { + assert.deepEqual(search(query), search(query, true)); + } +}); + +test('homepage relative ages advance without refreshing or changing the active search', async t => { + const { context } = startPage(t, { trending: new Promise(() => {}) }); + await new Promise(setImmediate); + vm.runInContext("state.query = 'php2shell'; Date.now = () => Date.parse('2026-10-21T20:00:00Z')", context); + const cell = { getAttribute: () => '2026-10-21T19:00:00Z', outerHTML: '' }; + context.document.querySelectorAll = selector => selector === 'time.trend-age' ? [cell] : []; + vm.runInContext('refreshTrendAges()', context); + assert.match(cell.outerHTML, />1 hour ago<\/time>/); + vm.runInContext("Date.now = () => Date.parse('2026-10-21T21:00:00Z'); refreshTrendAges()", context); + assert.match(cell.outerHTML, />2 hours ago<\/time>/); + assert.equal(vm.runInContext('state.query', context), 'php2shell'); +}); + test('pending CVEs are searchable by identifier and PoC name without NVD metadata', async t => { const entries = ['CVE-2026-109618', 'CVE-2026-109619'].map(cve => ({ cve, desc: 'Official CVE details are pending.', metadata_pending: true,