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"[^>]*>([^<]*)(?:span|time)>', 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,