Files
cve/scripts/build_seo.py
T
0xMarcio 0e5e8d7e8c Fix correctness, security and consistency bugs across the generated site
The per-CVE pages read as a different site and disagreed with search about
their own contents. Both traced to the page generator having grown its own
copy of rules the browser already had.

Security:
- JSON-LD was serialised with json.dumps alone, which leaves < > and & raw. A
  description containing "</script>" ended the element and turned the rest into
  live markup. 78 descriptions in the corpus already carry that sequence; only
  the 155-character meta trim kept them out of the payload. Escape to \u00XX.

Correctness:
- The PoC count summed only the repository column, so 19,940 pages announced
  "0 public PoC exploits" directly above the ExploitDB and Metasploit entries
  they were listing, in the title and meta description too. Count every source
  the page renders, matching POC_FIELDS in logic.js.
- The severity chip took the highest score while search took the first, so
  18,434 CVEs showed one severity in the results and another on the page. Both
  now take the primary assessment, with the same Nuclei fallback search uses.
- Identical CVSS rows printed two and three times on 13,340 pages. Dedupe on
  (version, score, vector) and label each survivor with its assessor from
  row[4]; row[5] is the assessment type and left 129 pages showing two rows
  both marked "Secondary".
- repo_from_url accepted any host containing "github" and kept .git suffixes,
  so a clone URL and its plain form counted as two sources on the page and one
  in search. 273 CVEs disagreed. Match repoFromUrl in logic.js exactly.
- 41 CVEs nobody has scored were unreachable by search, including by exact ID:
  the severity filter tested membership of a list naming only the four scored
  bands, so ALL excluded them. ALL now means all.
- EPSS percentiles printed a hard-coded "th": 51th, 62th, 93th on 21,100 pages.
- Search reported "1 PoCs", and the empty-results branch returned before
  updating the live region, leaving it reading "loading index..." forever.

Deploy safety:
- A failed EPSS fetch returned {} and published it, silently erasing every
  score from 82,000 pages. It now raises, as does a feed covering under 90%
  of the index.

Presentation:
- CVE pages never loaded the webfonts the homepage loads, so all 82,468 fell
  back to system fonts. This is why they looked unlike the rest of the site.
- 404.html referenced four CSS classes that no longer existed and rendered
  unstyled, and its meta refresh threw a reader out of a search after two
  seconds. It is now generated from the same constants and header as every
  other page, and offers the search box instead.
- The homepage and the generated footer credited different sources. Both now
  come from brand.SOURCES_LINE, and the build refuses to publish a homepage
  that has drifted from it.
- Three aria-labels sat on div elements with no role, where they are ignored.

SEO:
- 61,936 pages filled "Related" with numerically adjacent CVEs, which are not
  related to anything. Keep only shared-author relations; 20,532 pages have one.
- Sitemap lastmod described the CVE record, so a CVE that gained forty PoCs
  still advertised a date from 2021. It now reflects the page, and each shard
  carries its newest page's date rather than today's.
- TechArticle is not a type Google draws a rich result from, and its dates
  claimed the CVE record's publication as the page's. Use WebPage with the
  vulnerability as mainEntity.
- Curated sources and the CVE and NVD records no longer carry nofollow; the
  crowd-sourced repository list still does.
2026-09-02 17:17:28 +02:00

133 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Write docs/robots.txt and docs/sitemap.xml.
Both files were 404 before this, so nothing told a crawler where the site ends
or how often it changes. Everything here is derived from scripts/brand.py, so a
domain move is one edit rather than a hunt through the tree.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from brand import SITE
DOCS = os.path.join(ROOT, "docs")
ROBOTS = os.path.join(DOCS, "robots.txt")
SITEMAP = os.path.join(DOCS, "sitemap.xml")
# Googlebot renders the page before it indexes it, so anything the first paint
# needs has to stay crawlable. These three are small and carry the only server
# rendered content on the page, the trending table and the header counts.
RENDER_CRITICAL = ("trending_poc.json", "kev.json", "stats.json")
# These only feed the search box, which a crawler never types into. Together they
# are over 13 MB on the wire, which is crawl budget spent on nothing. Blocking
# them here does not touch curl: robots.txt binds crawlers, not clients.
BULK_PAYLOADS = (
"CVE_list.json",
"cve_metadata.json",
"epss.json",
"repo_meta.json",
"nuclei.json",
)
def robots() -> str:
lines = ["User-agent: *", "Allow: /"]
lines += [f"Disallow: /{name}" for name in BULK_PAYLOADS]
lines += ["", f"Sitemap: {SITE}/sitemap.xml", ""]
return "\n".join(lines)
def urlset(urls: list[tuple[str, str]]) -> str:
body = "".join(
f" <url>\n <loc>{loc}</loc>\n <lastmod>{lastmod}</lastmod>\n </url>\n"
for loc, lastmod in urls
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{body}</urlset>\n"
)
def sitemap_index(names: list[tuple[str, str]]) -> str:
body = "".join(
f" <sitemap>\n <loc>{SITE}/{name}</loc>\n"
f" <lastmod>{lastmod}</lastmod>\n </sitemap>\n"
for name, lastmod in names
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{body}</sitemapindex>\n"
)
def main() -> int:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
os.makedirs(DOCS, exist_ok=True)
with open(ROBOTS, "w", encoding="utf-8") as handle:
handle.write(robots())
# One sitemap per CVE year. The 50,000 URL cap makes some split necessary and
# the year is the split the data already has, which also means a crawler only
# refetches the shard that moved.
try:
with open(os.path.join(DOCS, "CVE_list.json"), encoding="utf-8") as handle:
cves = json.load(handle)
except FileNotFoundError:
cves = []
# build_pages.py works out when each page actually changed, taking the new
# PoC repositories into account and not only the CVE record date, and drops
# the answer here. Falling back to the record date keeps a standalone run
# working, just less accurately.
try:
with open(os.path.join(DOCS, "page_lastmod.json"), encoding="utf-8") as handle:
page_dates = json.load(handle)
except (OSError, json.JSONDecodeError):
page_dates = {}
years: dict[str, list[tuple[str, str]]] = {}
for entry in cves:
cid = entry["cve"]
year = cid.split("-")[1]
lastmod = page_dates.get(cid) or entry.get("modified") or entry.get("published") or today
years.setdefault(year, []).append((f"{SITE}/{cid}", lastmod))
# A shard's own lastmod is the newest page in it. Stamping every shard with
# today told a crawler all 29 had changed on every build, which is the kind
# of lastmod Google stops trusting and then ignores.
names: list[tuple[str, str]] = []
for year in sorted(years):
name = f"sitemap-{year}.xml"
with open(os.path.join(DOCS, name), "w", encoding="utf-8") as handle:
handle.write(urlset(years[year]))
names.append((name, max(date for _, date in years[year])))
with open(os.path.join(DOCS, "sitemap-home.xml"), "w", encoding="utf-8") as handle:
handle.write(urlset([(f"{SITE}/", today)]))
names.insert(0, ("sitemap-home.xml", today))
with open(SITEMAP, "w", encoding="utf-8") as handle:
handle.write(sitemap_index(names))
total = sum(len(v) for v in years.values()) + 1
sourced = sum(1 for entry in cves if page_dates.get(entry["cve"]))
print(f"Wrote {ROBOTS}, {SITEMAP} and {len(names)} shards ({total:,} URLs) for {SITE}; "
f"{sourced:,} lastmod values reflect page content")
return 0
if __name__ == "__main__":
raise SystemExit(main())