diff --git a/.github/getTrending.py b/.github/getTrending.py index 7509817b90a..3d73d9a7c34 100644 --- a/.github/getTrending.py +++ b/.github/getTrending.py @@ -36,6 +36,11 @@ MIN_STARS = 2 # short window and a hard cap. LANDED_DAYS = 10 LANDED_ROWS = 10 +# Candidates are judged newest push first, one gate query at a time, until no +# later push can place. The limit only matters when a flood of empty +# placeholders keeps the rows from filling. +LANDED_BATCH = 20 +LANDED_LIMIT = 200 NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=" # NVD allows five requests per thirty seconds unauthenticated, and only a # couple of rows per run ever need one. @@ -61,13 +66,18 @@ SEARCH_CTA_URL = f"https://raw.githubusercontent.com/{SLUG}/{SEARCH_CTA_REV}/doc KEV_MARK = f'KEV ' -def search(query: str) -> list[dict]: - """Every repository a search exposes, most recently updated first.""" +def search(query: str) -> tuple[int, list[dict]]: + """How many repositories a search matches, and every one it exposes, most + recently updated first.""" headers = {"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT} + # The token authenticates the API only; it is never placed in the URL, + # written to disk, or printed, so it cannot leak through logs or the commit. token = github_token() if token: headers["Authorization"] = f"Bearer {token}" found: list[dict] = [] + seen: set[str] = set() + total = 0 for page in range(1, SEARCH_LIMIT // SEARCH_PAGE + 1): # The REST API reads sort and order. The website's s and o are ignored # and leave the results in best-match order. @@ -83,7 +93,10 @@ def search(query: str) -> list[dict]: with request.urlopen(request.Request(url, headers=headers), timeout=30) as response: payload = json.load(response) except error.HTTPError as problem: - if problem.code not in (403, 422, 503) or attempt == 2: + if problem.code not in {403, 429, 500, 502, 503, 504} or attempt == 2: + raise + except (error.URLError, TimeoutError, json.JSONDecodeError): + if attempt == 2: raise else: # A search that runs out of time still answers, with whatever @@ -93,11 +106,18 @@ def search(query: str) -> list[dict]: if attempt == 2: raise RuntimeError(f"GitHub returned incomplete results for {query}") time.sleep(5 * (attempt + 1)) + total = int(payload.get("total_count") or 0) items = payload.get("items") or [] - found.extend(items) - if not items or len(found) >= min(int(payload.get("total_count") or 0), SEARCH_LIMIT): + for repo in items: + # Results shift between pages while they are read, so a repository + # can come back twice; GitHub names are case-insensitive. + name = str(repo.get("full_name") or "").lower() + if name and name not in seen: + seen.add(name) + found.append(repo) + if not items or len(found) >= min(total, SEARCH_LIMIT): break - return found + return total, found def nvd_description(cve: str) -> str: @@ -156,7 +176,7 @@ def just_landed(token: str) -> list[dict]: since = cutoff.isoformat() # Landed means created in the window. An old repository with a new commit # has not landed; a new PoC for a CVE from any year has. - rows = search(f"CVE in:name created:>={since}") + _, rows = search(f"CVE in:name created:>={since}") candidates, seen = [], set() for repo in rows: @@ -172,29 +192,34 @@ def just_landed(token: str) -> list[dict]: seen.add(name.lower()) candidates.append(repo) candidates.sort(key=lambda repo: str(repo.get("pushed_at") or ""), reverse=True) - # Only the freshest are worth two API round trips each. - candidates = candidates[:LANDED_ROWS * 4] - searched = len(candidates) - candidates, paths = qualifying_repositories(candidates, token) - names = [str(repo["full_name"]) for repo in candidates] - shipped = code_pushed(names, token, paths) - - fresh, hollow = [], 0 - for repo in candidates: - name = str(repo["full_name"]) - pushed = shipped.get(name) - if not pushed: - hollow += 1 - continue - if pushed[:10] < since: - continue - repo["_shipped"] = pushed - fresh.append(repo) - fresh.sort(key=lambda repo: repo["_shipped"], reverse=True) + pool = candidates[:LANDED_LIMIT] + fresh: list[dict] = [] + judged = rejected = hollow = 0 + for start in range(0, len(pool), LANDED_BATCH): + # An artifact date never follows its repository's last push, so once the + # last row is at least as new as the next push, nothing further down + # can place. + if len(fresh) >= LANDED_ROWS and ( + str(pool[start].get("pushed_at") or "") <= fresh[LANDED_ROWS - 1]["_shipped"] + ): + break + batch = pool[start : start + LANDED_BATCH] + judged += len(batch) + accepted, paths = qualifying_repositories(batch, token) + rejected += len(batch) - len(accepted) + shipped = code_pushed([str(repo["full_name"]) for repo in accepted], token, paths) + for repo in accepted: + pushed = shipped.get(str(repo["full_name"])) + if not pushed: + hollow += 1 + elif pushed[:10] >= since: + repo["_shipped"] = pushed + fresh.append(repo) + fresh.sort(key=lambda repo: repo["_shipped"], reverse=True) print( - f"just landed: {len(fresh)} qualified, " - f"{searched - len(candidates)} rejected by artifact gate, " + f"just landed: {judged} of {len(candidates)} candidates judged, " + f"{len(fresh)} qualified, {rejected} rejected by artifact gate, " f"{hollow} without an artifact commit" ) return fresh[:LANDED_ROWS] @@ -208,49 +233,7 @@ def search_year(year: int, since: str) -> tuple[int, list[dict]]: [f'"CVE-{year}" in:name', f"stars:>{MIN_STARS}", f"pushed:>{since}"] + [f"language:{language}" for language in LANGUAGES] ) - headers = {"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT} - # The token authenticates the API only; it is never placed in the URL, - # written to disk, or printed, so it cannot leak through logs or the commit. - token = github_token() - if token: - headers["Authorization"] = f"Bearer {token}" - - repositories: list[dict] = [] - seen: set[str] = set() - total = 0 - for page in range(1, SEARCH_LIMIT // SEARCH_PAGE + 1): - url = SEARCH_URL + "?" + parse.urlencode({ - "q": query, - "s": "updated", - "o": "desc", - "per_page": SEARCH_PAGE, - "page": page, - }) - for attempt in range(3): - try: - with request.urlopen( - request.Request(url, headers=headers), timeout=30 - ) as response: - payload = json.load(response) - break - except error.HTTPError as exc: - if exc.code not in {403, 429, 500, 502, 503, 504} or attempt == 2: - raise - except (error.URLError, TimeoutError, json.JSONDecodeError): - if attempt == 2: - raise - time.sleep(5 * (attempt + 1)) - - total = int(payload.get("total_count") or 0) - found = payload.get("items") or [] - for repo in found: - name = str(repo.get("full_name") or "") - if name and name not in seen: - seen.add(name) - repositories.append(repo) - if not found or len(repositories) >= min(total, SEARCH_LIMIT): - break - return total, repositories + return search(query) def github_token() -> str: @@ -259,6 +242,7 @@ def github_token() -> str: def graphql(query: str, token: str) -> dict: body = json.dumps({"query": query}).encode("utf-8") + aliases = set(re.findall(r"\b(r\d+)\s*:\s*repository\s*\(", query)) headers = { "Accept": "application/json", "Content-Type": "application/json", @@ -272,13 +256,31 @@ def graphql(query: str, token: str) -> dict: request.Request(GRAPHQL_URL, data=body, headers=headers, method="POST"), timeout=45, ) as response: - return json.load(response).get("data") or {} + payload = json.load(response) except error.HTTPError as exc: if exc.code not in {403, 429, 500, 502, 503, 504} or attempt == 2: raise except (error.URLError, TimeoutError, json.JSONDecodeError): if attempt == 2: raise + else: + data = payload.get("data") or {} + errors = payload.get("errors") or [] + # Only a confirmed missing repository is safe to skip. Nested + # lookup failures and partial responses must not remove good rows. + failed = [] + for item in errors: + path = item.get("path") or [] + if not ( + item.get("type") == "NOT_FOUND" and len(path) == 1 + and path[0] in aliases and path[0] in data and data[path[0]] is None + ): + failed.append(item) + if not failed and aliases.issubset(data): + return data + if attempt == 2: + reason = (failed[0].get("message") or failed[0]) if failed else "missing repository data" + raise RuntimeError(f"GitHub GraphQL failed: {reason}") time.sleep(4 * (attempt + 1)) return {} @@ -458,7 +460,7 @@ def repository_summary(repo: dict, cve: str) -> str: remainder, ) if not remainder or placeholder: - return nvd_description(cve) or summary + return cell(nvd_description(cve)) or summary return summary @@ -647,7 +649,7 @@ Advisory rows are `[URL, NVD reference tags]`. | [ExploitDB](https://gitlab.com/exploit-database/exploitdb) | Archived exploits, mapped by their own CVE column | | [Metasploit](https://github.com/rapid7/metasploit-framework) | Modules, best ranked first | | [Vulhub](https://github.com/vulhub/vulhub) | Runnable vulnerable environments and reproduction steps | -| [afrog](https://github.com/zan8in/afrog), [Vulnerability](https://github.com/tzwlhack/Vulnerability), [0day](https://github.com/helloexp/0day), [xray](https://github.com/chaitin/xray) | CVE-specific templates, code and reproduction guides inside multi-CVE repositories | +| [afrog](https://github.com/zan8in/afrog), [Vulnerability](https://github.com/tzwlhack/Vulnerability), [0day](https://github.com/helloexp/0day), [xray](https://github.com/chaitin/xray), [Google Security Research](https://github.com/google/security-research), [GitHub Security Lab](https://github.com/github/securitylab) | CVE-specific templates, code and reproduction guides inside multi-CVE repositories | | [EPSS](https://www.first.org/epss/) | Daily exploitation probability from FIRST | | [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) | What is being exploited in the wild | | [NVD](https://nvd.nist.gov/) | CVSS assessments and tagged vendor, third-party, patch and mitigation references | @@ -657,7 +659,7 @@ Advisory rows are `[URL, NVD reference tags]`. | Job | Cadence | Picks up | | --- | --- | --- | -| [Trending sweep](.github/workflows/hot_cves.yml) | hourly | Front-page repositories and prior-hour candidates added to the searchable index | +| [Trending sweep](.github/workflows/hot_cves.yml) | hourly | Front-page repositories, also added to the searchable index | | [CVE sync](.github/workflows/sync_cve_pocs.yml) | daily | New CVEs, CNA references and recently pushed GitHub repositories for every CVE year | | [Metadata sync](.github/workflows/sync_metadata.yml) | daily plus weekly full pass | CVSS, advisories, rejected records and current CISA KEV status | | [Nuclei sync](.github/workflows/sync_nuclei.yml) | daily | New templates and rating changes | diff --git a/.github/workflows/hot_cves.yml b/.github/workflows/hot_cves.yml index ef7c296d7ad..f89ed97cbd2 100644 --- a/.github/workflows/hot_cves.yml +++ b/.github/workflows/hot_cves.yml @@ -39,11 +39,14 @@ jobs: run: python3 scripts/update_cves.py --lookback-days 1 --years 2 --skip-cvelist - name: Rebuild the trending PoC table + id: trending + continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: python3 .github/getTrending.py - name: Ingest the current candidates + if: steps.trending.outcome == 'success' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: python3 scripts/sync_pocingithub.py --input index/trending.json @@ -101,3 +104,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: gh workflow run site.yml --ref main + + - name: Report trending refresh failure + if: steps.trending.outcome == 'failure' + run: exit 1 diff --git a/README.md b/README.md index a4504b982c8..6f8ba7fc1f5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ PoC Index -[![last sync](https://img.shields.io/badge/last%20sync-06%20Oct%202026%2021%3A26%20UTC-2f81f7?style=flat-square&labelColor=161b22)](https://github.com/0xMarcio/pocindex/commits/main) [![CI](https://img.shields.io/github/actions/workflow/status/0xMarcio/pocindex/hot_cves.yml?style=flat-square&label=CI&color=2f81f7&labelColor=161b22&_=202610062126)](https://github.com/0xMarcio/pocindex/actions/workflows/hot_cves.yml) [![CVEs with PoCs](https://img.shields.io/badge/CVEs%20with%20PoCs-84%2C014-2f81f7?style=flat-square&labelColor=161b22)](https://pocindex.io/) [![known exploited](https://img.shields.io/badge/known%20exploited-1%2C221-f85149?style=flat-square&labelColor=161b22)](https://pocindex.io/) [![stars](https://img.shields.io/github/stars/0xMarcio/pocindex?style=flat-square&label=stars&color=e3b341&labelColor=161b22&_=202610062126)](https://github.com/0xMarcio/pocindex/stargazers) +[![last sync](https://img.shields.io/badge/last%20sync-06%20Oct%202026%2021%3A43%20UTC-2f81f7?style=flat-square&labelColor=161b22)](https://github.com/0xMarcio/pocindex/commits/main) [![CI](https://img.shields.io/github/actions/workflow/status/0xMarcio/pocindex/hot_cves.yml?style=flat-square&label=CI&color=2f81f7&labelColor=161b22&_=202610062143)](https://github.com/0xMarcio/pocindex/actions/workflows/hot_cves.yml) [![CVEs with PoCs](https://img.shields.io/badge/CVEs%20with%20PoCs-84%2C014-2f81f7?style=flat-square&labelColor=161b22)](https://pocindex.io/) [![known exploited](https://img.shields.io/badge/known%20exploited-1%2C221-f85149?style=flat-square&labelColor=161b22)](https://pocindex.io/) [![stars](https://img.shields.io/github/stars/0xMarcio/pocindex?style=flat-square&label=stars&color=e3b341&labelColor=161b22&_=202610062143)](https://github.com/0xMarcio/pocindex/stargazers) Search PoC Index @@ -14,15 +14,15 @@ | Stars | Updated | Repository | Description | | --- | --- | --- | --- | -| 2⭐ | 49m ago | [CVE-2026-103648](https://github.com/EterNullSec/CVE-2026-103648) | CVE-2026-103648 / Path Traversal in image-downloader 4.3.0 / CVSS 9.1 Critical / CWE-22 / By EterNullSec | +| 2⭐ | 1h ago | [CVE-2026-103648](https://github.com/EterNullSec/CVE-2026-103648) | CVE-2026-103648 / Path Traversal in image-downloader 4.3.0 / CVSS 9.1 Critical / CWE-22 / By EterNullSec | | 0⭐ | 1h ago | [CVE-2026-57967](https://github.com/c0dem4sters/CVE-2026-57967) | CVE-2026-57967 PoC - Apache ActiveMQ Artemis unauthenticated session hijacking via SESSION_REATTACH… | -| 0⭐ | 2h ago | [CVE-2026-21589-PoC-Exploit](https://github.com/tc4dy/CVE-2026-21589-PoC-Exploit) | CVE-2026-21589 - Atlassian Data Center Unauth Arbitrary File Read (CVSS 9.3) / Red/Blue Team suite. 2 tools:… | +| 0⭐ | 3h ago | [CVE-2026-21589-PoC-Exploit](https://github.com/tc4dy/CVE-2026-21589-PoC-Exploit) | CVE-2026-21589 - Atlassian Data Center Unauth Arbitrary File Read (CVSS 9.3) / Red/Blue Team suite. 2 tools:… | | 0⭐ | 3h ago | [Zenphoto-1.4.1.4-CVE-2011-4825-RCE](https://github.com/XavLimSG/Zenphoto-1.4.1.4-CVE-2011-4825-RCE) | Python 3 reverse-shell exploit for Zenphoto <= 1.4.1.4 / CVE-2011-4825 (EDB-18083) | | 1⭐ | 5h ago | [CVE-2026-3888-snap-confine-privilege-escalation](https://github.com/AlanNewberry/CVE-2026-3888-snap-confine-privilege-escalation) | Exploit para CVE-2026-3888: race condition en snap-confine con hijack del loader para escalar a root en Linux. | | 5⭐ | 5h ago | [CVE-2026-21589](https://github.com/MarcusProgram/CVE-2026-21589) | h3. Summary This is a vulnerability in Bitbucket Data Center, Confluence Data Center, Jira Service Management… | -| 0⭐ | 6h ago | [CVE-2026-67401](https://github.com/hitechcloud-vietnam/CVE-2026-67401) | PoC for CVE-2026-67401 - cPanel/WHM EmailTrack SQL injection leading to arbitrary file write and root RCE.… | +| 0⭐ | 7h ago | [CVE-2026-67401](https://github.com/hitechcloud-vietnam/CVE-2026-67401) | PoC for CVE-2026-67401 - cPanel/WHM EmailTrack SQL injection leading to arbitrary file write and root RCE.… | | 0⭐ | 7h ago | KEV [cve-2026-41940-PoC](https://github.com/hitechcloud-vietnam/cve-2026-41940-PoC) | A tool for exploiting CVE-2026-41940, a critical authentication bypass in cPanel & WHM (CVSS 10.0), allowing… | -| 0⭐ | 8h ago | [CVE-2025-34069](https://github.com/cppghoul/CVE-2025-34069) | Kerio Control 9.4.5 Auth Bypass PoC | +| 0⭐ | 9h ago | [CVE-2025-34069](https://github.com/cppghoul/CVE-2025-34069) | Kerio Control 9.4.5 Auth Bypass PoC | | 2⭐ | 10h ago | [watchTowr-vs-Atlassian-CVE-2026-21589](https://github.com/watchtowrlabs/watchTowr-vs-Atlassian-CVE-2026-21589) | h3. Summary This is a vulnerability in Bitbucket Data Center, Confluence Data Center, Jira Service Management… | ## Trending in 2026 @@ -142,7 +142,7 @@ Advisory rows are `[URL, NVD reference tags]`. | [ExploitDB](https://gitlab.com/exploit-database/exploitdb) | Archived exploits, mapped by their own CVE column | | [Metasploit](https://github.com/rapid7/metasploit-framework) | Modules, best ranked first | | [Vulhub](https://github.com/vulhub/vulhub) | Runnable vulnerable environments and reproduction steps | -| [afrog](https://github.com/zan8in/afrog), [Vulnerability](https://github.com/tzwlhack/Vulnerability), [0day](https://github.com/helloexp/0day), [xray](https://github.com/chaitin/xray) | CVE-specific templates, code and reproduction guides inside multi-CVE repositories | +| [afrog](https://github.com/zan8in/afrog), [Vulnerability](https://github.com/tzwlhack/Vulnerability), [0day](https://github.com/helloexp/0day), [xray](https://github.com/chaitin/xray), [Google Security Research](https://github.com/google/security-research), [GitHub Security Lab](https://github.com/github/securitylab) | CVE-specific templates, code and reproduction guides inside multi-CVE repositories | | [EPSS](https://www.first.org/epss/) | Daily exploitation probability from FIRST | | [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) | What is being exploited in the wild | | [NVD](https://nvd.nist.gov/) | CVSS assessments and tagged vendor, third-party, patch and mitigation references | @@ -152,7 +152,7 @@ Advisory rows are `[URL, NVD reference tags]`. | Job | Cadence | Picks up | | --- | --- | --- | -| [Trending sweep](.github/workflows/hot_cves.yml) | hourly | Front-page repositories and prior-hour candidates added to the searchable index | +| [Trending sweep](.github/workflows/hot_cves.yml) | hourly | Front-page repositories, also added to the searchable index | | [CVE sync](.github/workflows/sync_cve_pocs.yml) | daily | New CVEs, CNA references and recently pushed GitHub repositories for every CVE year | | [Metadata sync](.github/workflows/sync_metadata.yml) | daily plus weekly full pass | CVSS, advisories, rejected records and current CISA KEV status | | [Nuclei sync](.github/workflows/sync_nuclei.yml) | daily | New templates and rating changes | diff --git a/docs/logic.js b/docs/logic.js index 19ea28a3596..832ae352e8e 100644 --- a/docs/logic.js +++ b/docs/logic.js @@ -183,7 +183,9 @@ function pocText(entry) { function entryLinks(entry) { if (entry._allLinks === undefined) { - entry._allLinks = uniqueSourceLinks(POC_FIELDS.flatMap(field => entry[field] || [])); + entry._allLinks = uniqueSourceLinks( + POC_FIELDS.flatMap(field => entry[field] || []), + POC_FIELDS.slice(1).flatMap(field => entry[field] || [])); } return entry._allLinks; } @@ -804,7 +806,7 @@ function rankedLinks(entry) { ...(entry.nuclei || []), ...(entry.msf || []), ...(entry.edb || []), ...(entry.vulhub || []), ...(entry.collections || []), ...scored.map(item => item.url) - ]); + ], POC_FIELDS.slice(1).flatMap(field => entry[field] || [])); return entry._ranked; } @@ -829,16 +831,25 @@ function advisoryRow(item) { ''; } -function normalizedLink(url) { +function normalizedLink(url, preservePath = false) { const parsed = repoFromUrl(String(url || '')); - if (parsed) return `github:${parsed.owner.toLowerCase()}/${parsed.repo.toLowerCase()}`; + if (parsed) { + const key = `github:${parsed.owner.toLowerCase()}/${parsed.repo.toLowerCase()}`; + if (!preservePath) return key; + const location = new URL(url); + const path = location.pathname.split('/').filter(Boolean).slice(2).join('/'); + return `${key}/${path}${location.search}${location.hash}`; + } return String(url || '').replace(/\/$/, ''); } -function uniqueSourceLinks(urls) { +function uniqueSourceLinks(urls, curated = []) { const seen = new Set(); + const artifacts = new Set(curated); + const sources = new Set(curated.map(url => normalizedLink(url))); return urls.filter(url => { - const key = normalizedLink(url); + if (!artifacts.has(url) && sources.has(normalizedLink(url))) return false; + const key = normalizedLink(url, artifacts.has(url)); if (seen.has(key)) return false; seen.add(key); return true; @@ -859,7 +870,7 @@ function resultRow(entry) { : ''; const advisoryOpen = state.advisoryOpen.has(id); - const pocLinks = new Set(entryLinks(entry).map(normalizedLink)); + const pocLinks = new Set(entryLinks(entry).map(url => normalizedLink(url))); const advisories = ((metadata[id] || {}).advisories || []) .filter(item => Array.isArray(item) && !pocLinks.has(normalizedLink(item[0]))); const shownAdvisories = advisoryOpen ? advisories : advisories.slice(0, ADVISORY_PREVIEW); diff --git a/scripts/audit_poc_links.py b/scripts/audit_poc_links.py index 3b0dc43ef9d..c53cbbf9885 100644 --- a/scripts/audit_poc_links.py +++ b/scripts/audit_poc_links.py @@ -326,6 +326,8 @@ def refresh_kev(*, dry_run: bool) -> int: vulnerabilities = payload["vulnerabilities"] if type(payload.get("count")) is not int or payload["count"] != len(vulnerabilities): raise ValueError("KEV count does not match its vulnerability list") + if not vulnerabilities: + raise ValueError("Empty KEV catalogue") entries = {} for item in vulnerabilities: if not isinstance(item, dict): diff --git a/scripts/build_site.py b/scripts/build_site.py index cbe595868f5..844b1f68174 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -245,11 +245,11 @@ def poc_link_rank(url: str, cve_id: str) -> tuple[int, int]: return 4, len(url) -def dedupe_source_links(urls: Iterable[str], cve_id: str) -> List[str]: +def dedupe_source_links(urls: Iterable[str], cve_id: str, *, preserve_paths: bool = False) -> List[str]: selected: Dict[str, tuple[int, str]] = {} order: Dict[str, int] = {} for index, url in enumerate(urls): - key = source_key(url) + key = link_key(url) if preserve_paths else source_key(url) order.setdefault(key, index) current = selected.get(key) if current is None or poc_link_rank(url, cve_id) < poc_link_rank(current[1], cve_id): @@ -358,6 +358,7 @@ def build_cve_list(blacklist: Collection[str]) -> tuple[List[Dict[str, object]], field: dedupe_source_links( collect_links(sections.get(header, ""), blacklist=blacklist), cve_id, + preserve_paths=True, ) for header, field in CURATED } diff --git a/tests/test_audit_poc_links.py b/tests/test_audit_poc_links.py index 9c3327f6c01..133ff602f9c 100644 --- a/tests/test_audit_poc_links.py +++ b/tests/test_audit_poc_links.py @@ -94,6 +94,7 @@ class KevRefreshTests(unittest.TestCase): def test_incomplete_or_invalid_catalogue_preserves_stored_flags(self) -> None: row = self.row() invalid = [ + {"count": 0, "vulnerabilities": []}, {"count": 2, "vulnerabilities": [row]}, {"count": 2, "vulnerabilities": [row, row]}, {"count": 1, "vulnerabilities": [self.row("NOT-A-CVE")]}, @@ -110,19 +111,22 @@ class KevRefreshTests(unittest.TestCase): audit_poc_links, "http_json", return_value=payload ): audit_poc_links.refresh_kev(dry_run=False) - self.assertEqual(path.read_text(), old) + self.assertEqual(path.read_bytes(), old.encode("utf-8")) - def test_complete_catalogue_can_remove_entries(self) -> None: + def test_complete_nonempty_catalogue_can_add_and_remove_entries(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "kev.json" - for rows in ([self.row()], []): - path.write_text('{"CVE-2026-10000":["2026-01-01",0]}\n', encoding="utf-8") - with self.subTest(rows=rows), patch.object(audit_poc_links, "KEV_FILE", path), patch.object( - audit_poc_links, "http_json", return_value={"count": len(rows), "vulnerabilities": rows} - ): - self.assertEqual(audit_poc_links.refresh_kev(dry_run=False), len(rows)) - expected = {"CVE-2026-12345": ["2026-10-06", 1]} if rows else {} - self.assertEqual(json.loads(path.read_text()), expected) + path.write_text(json.dumps({ + "CVE-2026-10000": ["2026-01-01", 0], "CVE-2026-12345": ["2026-01-02", 0], + }), encoding="utf-8") + rows = [self.row(), self.row("CVE-2026-12346")] + with patch.object(audit_poc_links, "KEV_FILE", path), patch.object( + audit_poc_links, "http_json", return_value={"count": len(rows), "vulnerabilities": rows} + ): + self.assertEqual(audit_poc_links.refresh_kev(dry_run=False), len(rows)) + self.assertEqual(json.loads(path.read_text()), { + "CVE-2026-12345": ["2026-10-06", 1], "CVE-2026-12346": ["2026-10-06", 1], + }) def test_failed_atomic_replace_preserves_catalogue(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_get_trending.py b/tests/test_get_trending.py index 791d4f59c66..36aff3d9cad 100644 --- a/tests/test_get_trending.py +++ b/tests/test_get_trending.py @@ -4,6 +4,7 @@ import importlib.util import io import json import re +import sys import tempfile import unittest from contextlib import redirect_stdout @@ -16,9 +17,14 @@ ROOT = Path(__file__).resolve().parents[1] SPEC = importlib.util.spec_from_file_location("getTrending", str(ROOT / ".github" / "getTrending.py")) trending = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(trending) +sys.path.insert(0, str(ROOT / "scripts")) + +import sync_collections NOW = datetime.now(timezone.utc) CVE = f"CVE-{NOW.year}-21589" +ALIAS = re.compile(r'(r\d+): repository\(owner: "[^"]*", name: "([^"]*)"\)') +EMPTY = {"readmeMd": {"text": "Soon"}, "root": {"entries": [{"name": "README.md", "type": "blob"}]}} def stamp(days: float) -> str: @@ -44,7 +50,7 @@ def gate(query: str, token: str) -> dict: "readmeMd": {"text": f"Proof of concept for {name}"}, "root": {"entries": [{"name": "exploit.py", "type": "blob"}]}, } - for alias, name in re.findall(r'(r\d+): repository\(owner: "[^"]*", name: "([^"]*)"\)', query) + for alias, name in ALIAS.findall(query) } @@ -52,6 +58,38 @@ def shipped(names: list[str], token: str, paths: dict | None = None) -> dict[str return {name: stamp(0.2) for name in names} +def run_landed(rows: list[dict], dates: dict[str, str]) -> tuple[list[str], list[str]]: + """Run the landed lane over search rows. A repository with a code date in + dates carries an exploit; any other is an empty placeholder the real gate + rejects. Returns the listed names and every name the gate was asked about.""" + asked: list[str] = [] + + def graphql(query: str, token: str) -> dict: + found = gate(query, token) + for alias, name in ALIAS.findall(query): + asked.append(name) + if name not in dates: + found[alias] = EMPTY + return found + + def pushed(names: list[str], token: str, paths: dict | None = None) -> dict[str, str]: + return {name: dates[name.split("/", 1)[1]] for name in names} + + with patch.multiple(trending, search=lambda query: (len(rows), rows), graphql=graphql, code_pushed=pushed), \ + redirect_stdout(io.StringIO()): + listed = trending.just_landed("") + return [found["name"] for found in listed], asked + + +def pushed_rows(owner: str, first: int, count: int, created: float, newest: float) -> list[dict]: + """Repositories created days ago, last pushed from newest days ago, older by + a quarter hour each.""" + return [ + dict(repo(f"{owner}/CVE-2025-{first + i}", stamp(created)), pushed_at=stamp(newest + 0.01 * i)) + for i in range(count) + ] + + class JustLandedTests(unittest.TestCase): def test_lists_new_repositories_for_any_cve_year_once(self) -> None: rows = [ @@ -65,9 +103,9 @@ class JustLandedTests(unittest.TestCase): ] queries: list[str] = [] - def search(query: str) -> list[dict]: + def search(query: str) -> tuple[int, list[dict]]: queries.append(query) - return rows + return len(rows), rows with patch.multiple(trending, search=search, graphql=gate, code_pushed=shipped), \ redirect_stdout(io.StringIO()): @@ -81,6 +119,33 @@ class JustLandedTests(unittest.TestCase): self.assertIn("created:>=", queries[0]) self.assertNotRegex(queries[0], r"pushed:|CVE-\d{4}") + def test_empty_placeholders_cannot_crowd_out_fresh_pocs(self) -> None: + empty = pushed_rows("spam", 5000, 45, created=1, newest=0.01) + real = pushed_rows("acme", 1000, 12, created=3, newest=1) + listed, asked = run_landed(empty + real, {row["name"]: row["pushed_at"] for row in real}) + self.assertEqual(listed, [row["name"] for row in real[:trending.LANDED_ROWS]]) + self.assertEqual(len(asked), len(empty + real)) + + def test_judging_stops_once_no_later_push_can_place(self) -> None: + rows = pushed_rows("acme", 1000, 30, created=3, newest=0.01) + listed, asked = run_landed(rows, {row["name"]: row["pushed_at"] for row in rows}) + self.assertEqual(listed, [row["name"] for row in rows[:trending.LANDED_ROWS]]) + self.assertEqual(len(asked), trending.LANDED_BATCH) + + def test_a_later_push_that_can_still_place_is_judged(self) -> None: + # The newest pushes only touched paperwork; their code is days old. + touched = pushed_rows("acme", 1000, 20, created=6, newest=0.01) + newer = pushed_rows("acme", 2000, 10, created=3, newest=1) + dates = {row["name"]: stamp(5) for row in touched} + dates.update({row["name"]: row["pushed_at"] for row in newer}) + listed, asked = run_landed(touched + newer, dates) + self.assertEqual(listed, [row["name"] for row in newer]) + self.assertEqual(len(asked), 30) + + def test_judging_is_capped(self) -> None: + rows = pushed_rows("spam", 5000, trending.LANDED_LIMIT + 50, created=3, newest=0.001) + self.assertEqual(run_landed(rows, {}), ([], [row["name"] for row in rows[:trending.LANDED_LIMIT]])) + class MainTests(unittest.TestCase): def test_new_trending_repository_is_in_both_sections_and_written_once(self) -> None: @@ -96,7 +161,7 @@ class MainTests(unittest.TestCase): figures=lambda: {"total_cves": 3, "with_pocs": 2, "kev": 1}, known_exploited=lambda: {CVE}, search_year=lambda year, since: (2, [dict(both), dict(older)]) if year == NOW.year else (0, []), - search=lambda query: [dict(fresh), dict(both), dict(older)], + search=lambda query: (3, [dict(fresh), dict(both), dict(older)]), graphql=gate, code_pushed=shipped, ), redirect_stdout(io.StringIO()) as log: @@ -118,6 +183,17 @@ class MainTests(unittest.TestCase): ) self.assertIn("(3 repositories, 1 known-exploited)", log.getvalue()) + def test_every_ingested_collection_is_credited(self) -> None: + for source in (*sync_collections.SOURCES, *sync_collections.TREE_SOURCES): + self.assertIn(f"](https://github.com/{source[1]})", trending.FOOTER) + + def test_cve_summary_is_cleaned_like_repository_text(self) -> None: + with patch.object(trending, "nvd_description", return_value="Path traversal \N{EM DASH} remote | unauthenticated"): + self.assertEqual( + trending.repository_summary({"description": CVE}, CVE), + "Path traversal - remote / unauthenticated", + ) + class ArtifactDateTests(unittest.TestCase): def test_commit_clock_cannot_postdate_the_repository_push(self) -> None: @@ -138,6 +214,56 @@ class ArtifactDateTests(unittest.TestCase): self.assertEqual(trending.time_ago(stamp(-0.1)), "just now") +class GraphQLTests(unittest.TestCase): + def answer(self, payload: dict): + return lambda req, timeout: io.BytesIO(json.dumps(payload).encode()) + + def query(self) -> str: + return "query { " + " ".join( + trending.repository_alias(index, name, "name") + for index, name in enumerate(["owner/missing", "owner/healthy"]) + ) + " }" + + def test_partial_operational_failures_retry_then_abort(self) -> None: + for error in ( + {"type": "INTERNAL", "path": ["r0"]}, + {"type": "RATE_LIMITED", "path": ["r0"]}, + {"type": "NOT_FOUND", "path": ["r0", "root"]}, + ): + payload = {"data": {"r0": None, "r1": {"name": "healthy"}}, "errors": [error]} + with self.subTest(error=error), patch.object(trending.time, "sleep"), patch.object( + trending.request, "urlopen", side_effect=self.answer(payload) + ) as fetch: + with self.assertRaises(RuntimeError): + trending.graphql(self.query(), "test") + self.assertEqual(fetch.call_count, 3) + + def test_missing_alias_is_not_a_deleted_repository(self) -> None: + with patch.object(trending.time, "sleep"), patch.object( + trending.request, "urlopen", side_effect=self.answer({"data": {"r1": {"name": "healthy"}}}) + ) as fetch: + with self.assertRaises(RuntimeError): + trending.graphql(self.query(), "test") + self.assertEqual(fetch.call_count, 3) + + def test_failed_query_is_not_read_as_repositories_without_code(self) -> None: + limited = {"errors": [{"type": "RATE_LIMITED", "message": "API rate limit exceeded"}]} + with patch.object(trending.time, "sleep"), patch.object(trending.request, "urlopen", self.answer(limited)): + with self.assertRaisesRegex(RuntimeError, "rate limit"): + trending.code_pushed(["owner/repo"], "test", {"owner/repo": ["exploit.py"]}) + + def test_deleted_repository_leaves_only_that_one_out(self) -> None: + kept = {"pushedAt": stamp(1), "defaultBranchRef": {"target": {"p0": {"nodes": []}}}} + payload = { + "data": {"r0": None, "r1": kept}, + "errors": [ + {"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve to a Repository"}, + ], + } + with patch.object(trending.request, "urlopen", self.answer(payload)): + self.assertEqual(trending.graphql(self.query(), "test"), {"r0": None, "r1": kept}) + + class SearchTests(unittest.TestCase): def replies(self, *payloads: dict) -> tuple[list[str], object]: urls: list[str] = [] @@ -156,7 +282,8 @@ class SearchTests(unittest.TestCase): {"total_count": 101, "incomplete_results": False, "items": [{"full_name": "a/100"}]}, ) with patch.object(trending.request, "urlopen", urlopen): - self.assertEqual(len(trending.search("CVE in:name")), 101) + total, found = trending.search("CVE in:name") + self.assertEqual((total, len(found)), (101, 101)) params = [parse_qs(urlparse(url).query) for url in urls] self.assertEqual( [(p["sort"], p["order"], p["page"]) for p in params], @@ -169,11 +296,30 @@ class SearchTests(unittest.TestCase): with patch.object(trending.time, "sleep"): _, urlopen = self.replies(partial, complete) with patch.object(trending.request, "urlopen", urlopen): - self.assertEqual(trending.search("CVE in:name"), [{"full_name": "a/b"}]) + self.assertEqual(trending.search("CVE in:name"), (1, [{"full_name": "a/b"}])) _, urlopen = self.replies(partial, partial, partial) with patch.object(trending.request, "urlopen", urlopen), self.assertRaises(RuntimeError): trending.search("CVE in:name") + def test_year_search_keeps_its_filters_and_lists_each_repository_once(self) -> None: + urls, urlopen = self.replies( + {"total_count": 101, "incomplete_results": False, + "items": [{"full_name": f"a/{n}"} for n in range(100)]}, + # An update during the read shifts the pages; the boundary row comes back. + {"total_count": 101, "incomplete_results": False, + "items": [{"full_name": "A/99"}, {"full_name": "a/100"}]}, + ) + with patch.object(trending.request, "urlopen", urlopen): + total, found = trending.search_year(2026, "2026-07-08") + self.assertEqual((total, len(found), found[-1]["full_name"]), (101, 101, "a/100")) + params = parse_qs(urlparse(urls[0]).query) + self.assertEqual((params["sort"], params["order"]), (["updated"], ["desc"])) + self.assertNotIn("s", params) + self.assertEqual( + params["q"][0].split()[:4], + ['"CVE-2026"', "in:name", "stars:>2", "pushed:>2026-07-08"], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_search.js b/tests/test_search.js index 316bc554a2f..8cfd9e8acce 100644 --- a/tests/test_search.js +++ b/tests/test_search.js @@ -56,6 +56,29 @@ test('repository security advisories have GHSA filtering without repository star assert.equal(vm.runInContext(`repoFromUrl(${JSON.stringify(url)})`, context), null); }); +test('curated artifact paths survive search rendering without duplicate repository roots', async t => { + const { context } = startPage(t, { trending: new Promise(() => {}) }); + await new Promise(setImmediate); + const base = 'https://github.com/google/security-research'; + const variants = ['lts', 'mitigation'].map(kind => `${base}/tree/master/pocs/linux/kernelctf/CVE-2024-26642_${kind}`); + const template = 'https://github.com/projectdiscovery/nuclei-templates/blob/main/http/Test.yaml?version=One#Request'; + const entry = { cve: 'CVE-2024-26642', poc: [base, 'https://github.com/example/poc', 'https://github.com/EXAMPLE/POC/blob/main/exploit.py'], + collections: [...variants, variants[0].replace('google/security-research', 'GOOGLE/Security-Research')], + nuclei: [template, template.replace('Test.yaml', 'test.yaml')] }; + context.variantEntry = entry; + vm.runInContext(`metadata = { 'CVE-2024-26642': { advisories: [[${JSON.stringify(base)}, ['Exploit']]] } };`, context); + for (const fn of ['entryLinks', 'rankedLinks']) { + const links = Array.from(vm.runInContext(`${fn}(variantEntry)`, context)); + assert.equal(links.length, 5); + for (const url of [...variants, template, template.replace('Test.yaml', 'test.yaml')]) assert.ok(links.includes(url), url); + assert.ok(!links.includes(base)); + } + const html = vm.runInContext('resultRow(variantEntry)', context); + for (const url of variants) assert.ok(html.includes(`href="${url}"`)); + assert.doesNotMatch(html, /CREDIBLE ADVISORIES/); + assert.equal(vm.runInContext(`normalizedLink(${JSON.stringify(variants[0])}) === normalizedLink(${JSON.stringify(base)})`, context), true); +}); + test('a stalled trending feed does not block a bookmarked search', async t => { const { elements, calls } = startPage(t, { search: '?q=widget', trending: new Promise(() => {}) }); await new Promise(setImmediate); diff --git a/tests/test_update_cves.py b/tests/test_update_cves.py index 6daf8f1caea..93b52973796 100644 --- a/tests/test_update_cves.py +++ b/tests/test_update_cves.py @@ -459,6 +459,29 @@ ui.run(port=8080) class PublishedLinkDeduplicationTests(unittest.TestCase): + def test_curated_artifact_variants_survive_publication(self) -> None: + cve = "CVE-2024-26642" + base = "https://github.com/google/security-research" + variants = [f"{base}/tree/master/pocs/linux/kernelctf/{cve}_{kind}" for kind in ("lts", "mitigation")] + template = "https://github.com/projectdiscovery/nuclei-templates/blob/main/http/Test.yaml?version=One#Request" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "2024" / f"{cve}.md" + path.parent.mkdir() + path.write_text( + "### Description\nA vulnerability.\n\n#### Github\n- " + base + + "\n\n#### Collections\n" + "\n".join(f"- {url}" for url in [*variants, variants[0].replace("google/security-research", "GOOGLE/Security-Research")]) + + "\n\n#### Nuclei\n" + "\n".join(f"- {url}" for url in [template, template.replace("Test.yaml", "test.yaml")]) + "\n", + encoding="utf-8", + ) + with patch.object(build_site, "CVES", root), patch.object(build_site, "load_metadata", return_value={}), \ + patch.object(build_site, "load_dates", return_value={}), \ + patch.object(build_site, "load_verified_references", return_value=set()): + entries, _ = build_site.build_cve_list(set()) + self.assertEqual(entries[0]["collections"], variants) + self.assertEqual(entries[0]["nuclei"], [template, template.replace("Test.yaml", "test.yaml")]) + self.assertEqual(entries[0]["poc"], []) + def test_published_links_require_absolute_http_urls(self) -> None: valid = [ "https://Example.com/PoC.py?Version=One#Example",