diff --git a/.github/getTrending.py b/.github/getTrending.py
index 00960039ba6..66106eec039 100644
--- a/.github/getTrending.py
+++ b/.github/getTrending.py
@@ -45,6 +45,7 @@ USER_AGENT = "0xMarcio-cve-trending"
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 BRAND, SITE
from update_cves import load_blacklist, qualifying_repo_cves
README = os.path.join(ROOT, "README.md")
@@ -506,11 +507,11 @@ def refresh_count_header() -> int:
badges = {
"CVEs with PoCs": pill(
"CVEs with PoCs", f"{counts['with_pocs']:,}", "2f81f7",
- "https://cve.codepwn.win/",
+ f"{SITE}/",
),
"known exploited": pill(
"known exploited", f"{counts['kev']:,}", "f85149",
- "https://cve.codepwn.win/",
+ f"{SITE}/",
),
}
badge_replacements = 0
@@ -536,7 +537,7 @@ def header(stamp: str, synced: str) -> list[str]:
because GitHub proxies README images and would otherwise serve a stale copy
of a file that is redrawn whenever the index moves."""
counts = figures()
- home = "https://cve.codepwn.win/"
+ home = f"{SITE}/"
badges = [
pill("last sync", synced, "2f81f7", f"https://github.com/{SLUG}/commits/main"),
live_pill("actions/workflow/status/" + SLUG + "/hot_cves.yml", "CI", "2f81f7",
@@ -551,11 +552,11 @@ def header(stamp: str, synced: str) -> list[str]:
return [
'
',
"",
- f'
',
+ f'
',
"",
" ".join(badges),
"",
- f'
',
+ f'
',
"",
"
",
"",
@@ -570,25 +571,25 @@ Every file is plain JSON on the CDN. No key, no rate limit.
```bash
# everything the index knows about one CVE
-curl -s https://cve.codepwn.win/CVE_list.json \\
+curl -s @SITE@/CVE_list.json \\
| jq '.[] | select(.cve == "CVE-2021-44228") | {cve, poc: (.poc | length), nuclei, msf, edb, vulhub, collections}'
# every published CVSS assessment plus vetted advisory links
-curl -s https://cve.codepwn.win/cve_metadata.json | jq '."CVE-2021-44228"'
+curl -s @SITE@/cve_metadata.json | jq '."CVE-2021-44228"'
# likelihood of exploitation in the next 30 days
-curl -s https://cve.codepwn.win/epss.json | jq '."CVE-2021-44228"'
+curl -s @SITE@/epss.json | jq '."CVE-2021-44228"'
# stars and last push for one PoC repository; repository keys are lowercased
-curl -s https://cve.codepwn.win/repo_meta.json | jq '."sfewer-r7/cve-2026-55040"'
+curl -s @SITE@/repo_meta.json | jq '."sfewer-r7/cve-2026-55040"'
```
What CISA says is being exploited, that also has a PoC here, ranked by how
likely each is to be used next:
```bash
-curl -s https://cve.codepwn.win/kev.json -o kev.json
-curl -s https://cve.codepwn.win/epss.json -o epss.json
+curl -s @SITE@/kev.json -o kev.json
+curl -s @SITE@/epss.json -o epss.json
jq -n --slurpfile kev kev.json --slurpfile epss epss.json \\
'[$kev[0] | keys[] | select($epss[0][.]) | {cve: ., epss: $epss[0][.][0]}]
| sort_by(-.epss) | .[:10]'
@@ -596,13 +597,13 @@ jq -n --slurpfile kev kev.json --slurpfile epss epss.json \\
| Endpoint | Holds |
| --- | --- |
-| [`CVE_list.json`](https://cve.codepwn.win/CVE_list.json) | Every CVE with a linked PoC, its description and its `poc`, `nuclei`, `msf`, `edb`, `vulhub` and `collections` links |
-| [`cve_metadata.json`](https://cve.codepwn.win/cve_metadata.json) | NVD CVSS v2.0, v3.0, v3.1 and v4.0 assessments with vectors and vetted advisory links |
-| [`epss.json`](https://cve.codepwn.win/epss.json) | Exploitation probability and percentile, for nearly every CVE indexed |
-| [`nuclei.json`](https://cve.codepwn.win/nuclei.json) | Template metadata for the CVEs covered by a runnable Nuclei check |
-| [`kev.json`](https://cve.codepwn.win/kev.json) | CISA known exploited, keyed by CVE id |
-| [`repo_meta.json`](https://cve.codepwn.win/repo_meta.json) | Stars and last push date per PoC repository, keys lowercased |
-| [`trending_poc.json`](https://cve.codepwn.win/trending_poc.json) | Trending repositories plus index totals |
+| [`CVE_list.json`](@SITE@/CVE_list.json) | Every CVE with a linked PoC, its description and its `poc`, `nuclei`, `msf`, `edb`, `vulhub` and `collections` links |
+| [`cve_metadata.json`](@SITE@/cve_metadata.json) | NVD CVSS v2.0, v3.0, v3.1 and v4.0 assessments with vectors and vetted advisory links |
+| [`epss.json`](@SITE@/epss.json) | Exploitation probability and percentile, for nearly every CVE indexed |
+| [`nuclei.json`](@SITE@/nuclei.json) | Template metadata for the CVEs covered by a runnable Nuclei check |
+| [`kev.json`](@SITE@/kev.json) | CISA known exploited, keyed by CVE id |
+| [`repo_meta.json`](@SITE@/repo_meta.json) | Stars and last push date per PoC repository, keys lowercased |
+| [`trending_poc.json`](@SITE@/trending_poc.json) | Trending repositories plus index totals |
| [`cves/2026/CVE-2026-68138.md`](cves/2026/CVE-2026-68138.md) | Markdown copy of one CVE, one directory per year |
CVSS rows are `[version, score, severity, vector, source, assessment type]`.
@@ -641,7 +642,7 @@ Advisory rows are `[URL, NVD reference tags]`.
Missing PoC, wrong link, dead repository: open an issue with the CVE id and the
repository URL.
-"""
+""".replace("@SITE@", SITE)
def main() -> int:
diff --git a/.github/hero.py b/.github/hero.py
index a86d6b3362f..395e81b3153 100644
--- a/.github/hero.py
+++ b/.github/hero.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Render the CVE Radar hero banner and social card as self-contained SVG.
+"""Render the PoC Index hero banner and social card as self-contained SVG.
Both cards are plain SVG with no external font, image or stylesheet reference:
GitHub proxies README images through camo, which fetches the file on its own and
@@ -11,12 +11,17 @@ from __future__ import annotations
import importlib.util
import json
import os
+import sys
from urllib import request
ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
BUILD_SITE = os.path.join(ROOT, "scripts", "build_site.py")
-LIVE_STATS = "https://cve.codepwn.win/trending_poc.json"
-LIVE_KEV = "https://cve.codepwn.win/kev.json"
+sys.path.insert(0, os.path.join(ROOT, "scripts"))
+
+from brand import BRAND, SITE, SUBTITLE, host
+
+LIVE_STATS = f"{SITE}/trending_poc.json"
+LIVE_KEV = f"{SITE}/kev.json"
HERO = os.path.join(ROOT, "docs", "hero.svg")
SOCIAL = os.path.join(ROOT, "docs", "social-card.svg")
STATS = os.path.join(ROOT, "docs", "stats.json")
@@ -122,16 +127,16 @@ def hero(counts: dict) -> str:
)
)
return f"""
+ role="img" aria-label="{BRAND}, {group(counts['with_pocs'])} CVEs with public proof-of-concept exploits">
{sweep(1090, 90, 160, 0.3)}
CVE RADAR
+ fill="{FG}" letter-spacing="2">{BRAND}
- Public proof-of-concept exploits, indexed by CVE.
- cve.codepwn.win
+ {SUBTITLE}
+ {host()}
{blocks}
@@ -149,22 +154,22 @@ def social(counts: dict) -> str:
)
)
return f"""
+ role="img" aria-label="{BRAND}">
{sweep(1000, 300, 220, 0.26)}
CVE RADAR
+ fill="{FG}" letter-spacing="3">{BRAND}
- Public proof-of-concept exploits, indexed by CVE.
+ {SUBTITLE}
{blocks}
cve.codepwn.win
+ letter-spacing="1">{host()}
"""
diff --git a/.github/workflows/audit_poc_links.yml b/.github/workflows/audit_poc_links.yml
index 329ca96e4bb..9e89e108439 100644
--- a/.github/workflows/audit_poc_links.yml
+++ b/.github/workflows/audit_poc_links.yml
@@ -40,7 +40,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
.github/link_audit_state.json \
diff --git a/.github/workflows/hot_cves.yml b/.github/workflows/hot_cves.yml
index 3abcbe13fad..4baa24ec039 100644
--- a/.github/workflows/hot_cves.yml
+++ b/.github/workflows/hot_cves.yml
@@ -47,7 +47,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
README.md \
index/trending.json \
diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml
index 59e7bf31575..8e72d1aa676 100644
--- a/.github/workflows/site.yml
+++ b/.github/workflows/site.yml
@@ -1,4 +1,4 @@
-name: Build & Deploy CVE Radar
+name: Build & Deploy PoC Index
on:
schedule:
@@ -32,6 +32,9 @@ jobs:
- name: Build site data
run: python3 scripts/build_site.py
+ - name: Build robots.txt and sitemap.xml
+ run: python3 scripts/build_seo.py
+
- name: Verify published counts
run: |
python3 .github/hero.py
diff --git a/.github/workflows/sync_collections.yml b/.github/workflows/sync_collections.yml
index 8b975ba4c05..cd40a1815a6 100644
--- a/.github/workflows/sync_collections.yml
+++ b/.github/workflows/sync_collections.yml
@@ -40,7 +40,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
README.md \
docs/hero.svg \
diff --git a/.github/workflows/sync_cve_pocs.yml b/.github/workflows/sync_cve_pocs.yml
index 2258a39e702..b7675033b50 100644
--- a/.github/workflows/sync_cve_pocs.yml
+++ b/.github/workflows/sync_cve_pocs.yml
@@ -42,7 +42,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
.github/cve_sync_state.json \
diff --git a/.github/workflows/sync_exploits.yml b/.github/workflows/sync_exploits.yml
index a70a38f4513..55fa3a990ca 100644
--- a/.github/workflows/sync_exploits.yml
+++ b/.github/workflows/sync_exploits.yml
@@ -40,7 +40,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
README.md \
docs/hero.svg \
diff --git a/.github/workflows/sync_metadata.yml b/.github/workflows/sync_metadata.yml
index e2bf9d2b661..26289848af4 100644
--- a/.github/workflows/sync_metadata.yml
+++ b/.github/workflows/sync_metadata.yml
@@ -57,7 +57,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add -- \
README.md \
cves/ \
diff --git a/.github/workflows/sync_nuclei.yml b/.github/workflows/sync_nuclei.yml
index fa89c1e4c35..4ecb526d6fb 100644
--- a/.github/workflows/sync_nuclei.yml
+++ b/.github/workflows/sync_nuclei.yml
@@ -40,7 +40,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
README.md \
docs/hero.svg \
diff --git a/.github/workflows/sync_pocingithub.yml b/.github/workflows/sync_pocingithub.yml
index d4fc61c2559..58d1626a75a 100644
--- a/.github/workflows/sync_pocingithub.yml
+++ b/.github/workflows/sync_pocingithub.yml
@@ -46,7 +46,7 @@ jobs:
- name: Check for changes and commit
run: |
git config user.name 'CVE-BOT'
- git config user.email 'cve@codepwn.win'
+ git config user.email 'bot@pocindex.io'
git add --all -- \
README.md \
docs/hero.svg \
diff --git a/.gitignore b/.gitignore
index 5a53b5e2b91..11fbe576a69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@ docs/nuclei.json
docs/cve_metadata.json
docs/epss.json
docs/api/v1/cve/
+docs/robots.txt
+docs/sitemap.xml
__pycache__/
# Assistant tooling. None of it belongs in the repository.
diff --git a/README.md b/README.md
index ed4d97a4fb7..af74d844ef4 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-
+
-[](https://github.com/0xMarcio/cve/commits/main) [](https://github.com/0xMarcio/cve/actions/workflows/hot_cves.yml) [](https://cve.codepwn.win/) [](https://cve.codepwn.win/) [](https://github.com/0xMarcio/cve/stargazers)
+[](https://github.com/0xMarcio/cve/commits/main) [](https://github.com/0xMarcio/cve/actions/workflows/hot_cves.yml) [](https://pocindex.io/) [](https://pocindex.io/) [](https://github.com/0xMarcio/cve/stargazers)
-
+
@@ -107,25 +107,25 @@ Every file is plain JSON on the CDN. No key, no rate limit.
```bash
# everything the index knows about one CVE
-curl -s https://cve.codepwn.win/CVE_list.json \
+curl -s https://pocindex.io/CVE_list.json \
| jq '.[] | select(.cve == "CVE-2021-44228") | {cve, poc: (.poc | length), nuclei, msf, edb, vulhub, collections}'
# every published CVSS assessment plus vetted advisory links
-curl -s https://cve.codepwn.win/cve_metadata.json | jq '."CVE-2021-44228"'
+curl -s https://pocindex.io/cve_metadata.json | jq '."CVE-2021-44228"'
# likelihood of exploitation in the next 30 days
-curl -s https://cve.codepwn.win/epss.json | jq '."CVE-2021-44228"'
+curl -s https://pocindex.io/epss.json | jq '."CVE-2021-44228"'
# stars and last push for one PoC repository; repository keys are lowercased
-curl -s https://cve.codepwn.win/repo_meta.json | jq '."sfewer-r7/cve-2026-55040"'
+curl -s https://pocindex.io/repo_meta.json | jq '."sfewer-r7/cve-2026-55040"'
```
What CISA says is being exploited, that also has a PoC here, ranked by how
likely each is to be used next:
```bash
-curl -s https://cve.codepwn.win/kev.json -o kev.json
-curl -s https://cve.codepwn.win/epss.json -o epss.json
+curl -s https://pocindex.io/kev.json -o kev.json
+curl -s https://pocindex.io/epss.json -o epss.json
jq -n --slurpfile kev kev.json --slurpfile epss epss.json \
'[$kev[0] | keys[] | select($epss[0][.]) | {cve: ., epss: $epss[0][.][0]}]
| sort_by(-.epss) | .[:10]'
@@ -133,13 +133,13 @@ jq -n --slurpfile kev kev.json --slurpfile epss epss.json \
| Endpoint | Holds |
| --- | --- |
-| [`CVE_list.json`](https://cve.codepwn.win/CVE_list.json) | Every CVE with a linked PoC, its description and its `poc`, `nuclei`, `msf`, `edb`, `vulhub` and `collections` links |
-| [`cve_metadata.json`](https://cve.codepwn.win/cve_metadata.json) | NVD CVSS v2.0, v3.0, v3.1 and v4.0 assessments with vectors and vetted advisory links |
-| [`epss.json`](https://cve.codepwn.win/epss.json) | Exploitation probability and percentile, for nearly every CVE indexed |
-| [`nuclei.json`](https://cve.codepwn.win/nuclei.json) | Template metadata for the CVEs covered by a runnable Nuclei check |
-| [`kev.json`](https://cve.codepwn.win/kev.json) | CISA known exploited, keyed by CVE id |
-| [`repo_meta.json`](https://cve.codepwn.win/repo_meta.json) | Stars and last push date per PoC repository, keys lowercased |
-| [`trending_poc.json`](https://cve.codepwn.win/trending_poc.json) | Trending repositories plus index totals |
+| [`CVE_list.json`](https://pocindex.io/CVE_list.json) | Every CVE with a linked PoC, its description and its `poc`, `nuclei`, `msf`, `edb`, `vulhub` and `collections` links |
+| [`cve_metadata.json`](https://pocindex.io/cve_metadata.json) | NVD CVSS v2.0, v3.0, v3.1 and v4.0 assessments with vectors and vetted advisory links |
+| [`epss.json`](https://pocindex.io/epss.json) | Exploitation probability and percentile, for nearly every CVE indexed |
+| [`nuclei.json`](https://pocindex.io/nuclei.json) | Template metadata for the CVEs covered by a runnable Nuclei check |
+| [`kev.json`](https://pocindex.io/kev.json) | CISA known exploited, keyed by CVE id |
+| [`repo_meta.json`](https://pocindex.io/repo_meta.json) | Stars and last push date per PoC repository, keys lowercased |
+| [`trending_poc.json`](https://pocindex.io/trending_poc.json) | Trending repositories plus index totals |
| [`cves/2026/CVE-2026-68138.md`](cves/2026/CVE-2026-68138.md) | Markdown copy of one CVE, one directory per year |
CVSS rows are `[version, score, severity, vector, source, assessment type]`.
diff --git a/docs/CNAME b/docs/CNAME
index 4034a05272f..fc709dce326 100644
--- a/docs/CNAME
+++ b/docs/CNAME
@@ -1 +1 @@
-cve.codepwn.win
\ No newline at end of file
+pocindex.io
diff --git a/docs/hero.svg b/docs/hero.svg
index 39f1d244a13..71e12417e98 100644
--- a/docs/hero.svg
+++ b/docs/hero.svg
@@ -1,5 +1,5 @@
+ role="img" aria-label="PoC Index, 82,409 CVEs with public proof-of-concept exploits">
@@ -13,10 +13,10 @@
CVE RADAR
+ fill="#e6edf3" letter-spacing="2">PoC Index
Public proof-of-concept exploits, indexed by CVE.
- cve.codepwn.win
+ pocindex.io
170,946
diff --git a/docs/index.html b/docs/index.html
index 03996b7b286..c8d7e2d777b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -3,22 +3,46 @@
- CVE PoC Hub
-
+
+ PoC Index: Search CVE Proof-of-Concept Exploits
+
+
-
+
-
-
+
+
-
-
+
+
-
+
-
+
+
+
@@ -35,7 +59,7 @@
-
CVE RADAR
+
PoC Index
Public proof-of-concept exploits, indexed by CVE.
@@ -108,6 +132,9 @@
sources: nvd · cve program · github · github advisories · cisa vulnrichment · cisa kev · first epss
+
+ CVE is a trademark of The MITRE Corporation. PoC Index is not affiliated with or endorsed by MITRE or the CVE Program.
+
diff --git a/docs/logic.js b/docs/logic.js
index e48174bda74..a81ea9d101f 100644
--- a/docs/logic.js
+++ b/docs/logic.js
@@ -702,7 +702,38 @@ function idleStatus() {
return '';
}
+/* ---- 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
+// is also the route the SearchAction in index.html declares.
+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;
+}
+
+// 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');
+ const search = params.toString();
+ const next = search ? `${location.pathname}?${search}` : location.pathname;
+ if (next !== location.pathname + location.search) history.replaceState(null, '', next);
+ }, 300);
+}
+
function render() {
+ syncURL();
if (state.query.length < MIN_QUERY && !hasActiveFilters()) {
el.status.textContent = state.ready ? idleStatus() : 'loading index…';
renderTrending();
@@ -914,6 +945,8 @@ async function loadJSON(url, options) {
} catch (err) {
console.warn(err.message);
}
+ readURLState();
+ syncFilterControls();
paintHeroStats();
render();
diff --git a/docs/search.svg b/docs/search.svg
index 8689ba54b65..8f996b13c83 100644
--- a/docs/search.svg
+++ b/docs/search.svg
@@ -1,5 +1,5 @@
+ role="img" aria-label="Search PoC Index">
diff --git a/docs/social-card.svg b/docs/social-card.svg
index 1133f78ee8d..92f1a07c7ec 100644
--- a/docs/social-card.svg
+++ b/docs/social-card.svg
@@ -1,5 +1,5 @@
+ role="img" aria-label="PoC Index">
@@ -14,7 +14,7 @@
CVE RADAR
+ fill="#e6edf3" letter-spacing="3">PoC Index
Public proof-of-concept exploits, indexed by CVE.
@@ -31,5 +31,5 @@
fill="#6e7681" letter-spacing="1.6">KNOWN EXPLOITED
cve.codepwn.win
+ letter-spacing="1">pocindex.io
diff --git a/docs/style.css b/docs/style.css
index e2e8cdf7048..722ed96749a 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -1,4 +1,4 @@
-/* CVE PoC Hub. GitHub Primer dark_default tokens, per the refit handoff. */
+/* PoC Index. GitHub Primer dark_default tokens, per the refit handoff. */
:root {
--canvas: #0d1117;
--canvas-subtle: #161b22;
@@ -520,7 +520,9 @@ a.chip:hover { color: var(--fg); border-color: var(--fg-muted); }
font: 400 11px/1.6 var(--mono);
color: var(--fg-muted);
}
-.site-footer span:last-child { margin-left: auto; }
+.site-footer .container:first-child span:last-child { margin-left: auto; }
+.site-legal { padding-top: 8px; }
+.site-legal span { margin-left: 0; opacity: .72; }
/* ---- narrow viewports --------------------------------------------------- */
diff --git a/scripts/brand.py b/scripts/brand.py
new file mode 100644
index 00000000000..f84e5c52b39
--- /dev/null
+++ b/scripts/brand.py
@@ -0,0 +1,46 @@
+"""Every user-facing name and URL, in one place.
+
+The site header, the browser title, the social card, the README banner and the
+GitHub About box all have to say the same thing. When they disagree Google picks
+its own site name, and mentions stop compounding into one brand. Change a string
+here and every generated surface follows.
+"""
+
+BRAND = "PoC Index"
+# Short line under the wordmark, on the site and on both cards.
+SUBTITLE = "Public proof-of-concept exploits, indexed by CVE."
+TITLE = f"{BRAND}: Search CVE Proof-of-Concept Exploits"
+# og:description and twitter:description. Shorter than DESCRIPTION because a
+# social card truncates far earlier than a search result does.
+SOCIAL_DESCRIPTION = (
+ f"{SUBTITLE} Searchable, refreshed hourly, no signup."
+)
+DESCRIPTION = (
+ "Search 82,000+ public CVE proof-of-concept exploits from GitHub, Nuclei, "
+ "ExploitDB, Metasploit and Vulhub. Updated hourly. Free JSON API, no signup."
+)
+# Shown under the GitHub About box and in search results for the repository.
+# Deliberately unlike the upstream wording: 167 forks carry the old description
+# verbatim, so a generic line ranks the copies alongside the original.
+REPO_DESCRIPTION = (
+ "Search public CVE proof-of-concept exploits from GitHub, Nuclei, ExploitDB, "
+ "Metasploit and Vulhub. 82,000+ CVEs with PoCs, updated hourly with CVSS, KEV "
+ "and EPSS. Free JSON API, no signup."
+)
+
+# Flip to https://pocindex.io only after DNS resolves and GitHub Pages has issued
+# the certificate. A canonical pointing at a host that does not answer yet drops
+# the page out of the index, so this trails the DNS cutover, it does not lead it.
+SITE = "https://pocindex.io"
+SLUG = "0xMarcio/cve"
+
+# CVE is a MITRE trademark, so it stays out of the brand and carries a notice.
+LEGAL = (
+ "CVE is a trademark of The MITRE Corporation. "
+ f"{BRAND} is not affiliated with or endorsed by MITRE or the CVE Program."
+)
+
+
+def host() -> str:
+ """SITE without the scheme, for the places that display a bare domain."""
+ return SITE.split("://", 1)[-1].rstrip("/")
diff --git a/scripts/build_seo.py b/scripts/build_seo.py
new file mode 100644
index 00000000000..f4b0367bd23
--- /dev/null
+++ b/scripts/build_seo.py
@@ -0,0 +1,76 @@
+#!/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 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 sitemap(urls: list[tuple[str, str]]) -> str:
+ body = "".join(
+ f" \n {loc} \n {lastmod} \n \n"
+ for loc, lastmod in urls
+ )
+ return (
+ '\n'
+ '\n'
+ f"{body}"
+ " \n"
+ )
+
+
+def main() -> int:
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ urls = [(f"{SITE}/", today)]
+
+ os.makedirs(DOCS, exist_ok=True)
+ with open(ROBOTS, "w", encoding="utf-8") as handle:
+ handle.write(robots())
+ with open(SITEMAP, "w", encoding="utf-8") as handle:
+ handle.write(sitemap(urls))
+
+ print(f"Wrote {ROBOTS} and {SITEMAP} ({len(urls)} URL) for {SITE}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())