limbowatch 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. limbowatch-0.1.0a1/.gitignore +29 -0
  2. limbowatch-0.1.0a1/PKG-INFO +40 -0
  3. limbowatch-0.1.0a1/README.md +7 -0
  4. limbowatch-0.1.0a1/pyproject.toml +45 -0
  5. limbowatch-0.1.0a1/src/limbowatch/__init__.py +3 -0
  6. limbowatch-0.1.0a1/src/limbowatch/classify.py +77 -0
  7. limbowatch-0.1.0a1/src/limbowatch/csvsafe.py +18 -0
  8. limbowatch-0.1.0a1/src/limbowatch/ecosystems.py +123 -0
  9. limbowatch-0.1.0a1/src/limbowatch/http.py +229 -0
  10. limbowatch-0.1.0a1/src/limbowatch/inventory/__init__.py +5 -0
  11. limbowatch-0.1.0a1/src/limbowatch/inventory/checks.py +161 -0
  12. limbowatch-0.1.0a1/src/limbowatch/inventory/osv_scanner.py +219 -0
  13. limbowatch-0.1.0a1/src/limbowatch/inventory/purl.py +50 -0
  14. limbowatch-0.1.0a1/src/limbowatch/inventory/sbom.py +67 -0
  15. limbowatch-0.1.0a1/src/limbowatch/models.py +178 -0
  16. limbowatch-0.1.0a1/src/limbowatch/normalize.py +101 -0
  17. limbowatch-0.1.0a1/src/limbowatch/publish/__init__.py +227 -0
  18. limbowatch-0.1.0a1/src/limbowatch/publish/feed.py +165 -0
  19. limbowatch-0.1.0a1/src/limbowatch/publish/quality.py +153 -0
  20. limbowatch-0.1.0a1/src/limbowatch/publish/report.py +242 -0
  21. limbowatch-0.1.0a1/src/limbowatch/publish/site.py +1866 -0
  22. limbowatch-0.1.0a1/src/limbowatch/py.typed +0 -0
  23. limbowatch-0.1.0a1/src/limbowatch/schedule.py +60 -0
  24. limbowatch-0.1.0a1/src/limbowatch/sources/__init__.py +1 -0
  25. limbowatch-0.1.0a1/src/limbowatch/sources/cve.py +79 -0
  26. limbowatch-0.1.0a1/src/limbowatch/sources/github.py +61 -0
  27. limbowatch-0.1.0a1/src/limbowatch/sources/nvd.py +29 -0
  28. limbowatch-0.1.0a1/src/limbowatch/sources/osv.py +25 -0
  29. limbowatch-0.1.0a1/src/limbowatch/store.py +160 -0
  30. limbowatch-0.1.0a1/src/limbowatch/tracker.py +429 -0
  31. limbowatch-0.1.0a1/src/limbowatch/versions.py +326 -0
  32. limbowatch-0.1.0a1/src/limbowatch/watch/__init__.py +1 -0
  33. limbowatch-0.1.0a1/src/limbowatch/watch/candidates.py +82 -0
  34. limbowatch-0.1.0a1/src/limbowatch/watch/issues.py +209 -0
  35. limbowatch-0.1.0a1/src/limbowatch/watch/mapping.py +88 -0
  36. limbowatch-0.1.0a1/src/limbowatch/watch/match.py +152 -0
  37. limbowatch-0.1.0a1/src/limbowatch/watch/output.py +788 -0
  38. limbowatch-0.1.0a1/src/limbowatch/watch/scan.py +114 -0
  39. limbowatch-0.1.0a1/src/limbowatch/watch/state.py +108 -0
  40. limbowatch-0.1.0a1/src/limbowatch/watch/trackers.py +203 -0
  41. limbowatch-0.1.0a1/src/limbowatch/watch/upstream.py +58 -0
@@ -0,0 +1,29 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ *.db
13
+ *.sqlite
14
+ .env
15
+ .DS_Store
16
+
17
+ # research caches (raw API responses, cloned lockfiles)
18
+ research/**/.cache/
19
+ # research intermediates: contain advisory text and author/credit data; never commit
20
+ research/**/data/**/*.json
21
+ !research/**/data/**/summary.json
22
+ !research/**/data/**/followup-*.json
23
+
24
+ # tracker runtime output (state lives on the tracker-data branch; site is built in CI)
25
+ tracker/state/
26
+ tracker/site/
27
+
28
+ # Site build output (written by CI before docker build)
29
+ deploy/site/public/
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: limbowatch
3
+ Version: 0.1.0a1
4
+ Summary: Track published security advisories stuck in limbo (not yet reviewed into the databases scanners read)
5
+ Project-URL: Homepage, https://limbo.watch
6
+ Project-URL: Source, https://github.com/limbowatch/limbowatch
7
+ Project-URL: Documentation, https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md
8
+ Project-URL: Issues, https://github.com/limbowatch/limbowatch/issues
9
+ Project-URL: Changelog, https://github.com/limbowatch/limbowatch/blob/main/CHANGELOG.md
10
+ Project-URL: Tracker, https://limbo.watch/tracker/
11
+ Author: Limbowatch contributors
12
+ License-Expression: Apache-2.0
13
+ Keywords: advisories,cyclonedx,dependencies,ghsa,osv,sarif,sca,security,supply-chain,vulnerabilities
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Security
25
+ Classifier: Topic :: Software Development :: Quality Assurance
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.12
28
+ Requires-Dist: httpx>=0.27
29
+ Requires-Dist: pydantic>=2.8
30
+ Requires-Dist: sqlalchemy>=2.0
31
+ Requires-Dist: univers>=30
32
+ Description-Content-Type: text/markdown
33
+
34
+ # limbowatch
35
+
36
+ The library behind [Limbowatch](https://limbo.watch): it tracks security advisories **in limbo**, meaning published by maintainers on GitHub but not yet in GitHub's reviewed Advisory Database or OSV, so scanners that read those databases don't report them.
37
+
38
+ Most people want the command line instead: `pip install limbowatch-cli`. See the [watcher guide](https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md).
39
+
40
+ Advisory data: GitHub repository security advisories and the GitHub Advisory Database (CC BY 4.0), via the limbo feed (CC BY 4.0). Code: Apache-2.0.
@@ -0,0 +1,7 @@
1
+ # limbowatch
2
+
3
+ The library behind [Limbowatch](https://limbo.watch): it tracks security advisories **in limbo**, meaning published by maintainers on GitHub but not yet in GitHub's reviewed Advisory Database or OSV, so scanners that read those databases don't report them.
4
+
5
+ Most people want the command line instead: `pip install limbowatch-cli`. See the [watcher guide](https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md).
6
+
7
+ Advisory data: GitHub repository security advisories and the GitHub Advisory Database (CC BY 4.0), via the limbo feed (CC BY 4.0). Code: Apache-2.0.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "limbowatch"
3
+ version = "0.1.0a1"
4
+ description = "Track published security advisories stuck in limbo (not yet reviewed into the databases scanners read)"
5
+ license = "Apache-2.0"
6
+ requires-python = ">=3.12"
7
+ readme = "README.md"
8
+ authors = [{ name = "Limbowatch contributors" }]
9
+ keywords = ["security", "advisories", "vulnerabilities", "ghsa", "osv", "sca", "dependencies", "supply-chain", "sarif", "cyclonedx"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Information Technology",
14
+ "Intended Audience :: System Administrators",
15
+ "Operating System :: MacOS",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Security",
22
+ "Topic :: Software Development :: Quality Assurance",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27",
27
+ "pydantic>=2.8",
28
+ "sqlalchemy>=2.0",
29
+ "univers>=30",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://limbo.watch"
34
+ Source = "https://github.com/limbowatch/limbowatch"
35
+ Documentation = "https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md"
36
+ Issues = "https://github.com/limbowatch/limbowatch/issues"
37
+ Changelog = "https://github.com/limbowatch/limbowatch/blob/main/CHANGELOG.md"
38
+ Tracker = "https://limbo.watch/tracker/"
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/limbowatch"]
@@ -0,0 +1,3 @@
1
+ """Limbowatch: track published security advisories stuck in limbo."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,77 @@
1
+ """Classify an advisory from its facts. Pure: no I/O.
2
+
3
+ Facts are independent (repo advisory, review, OSV, CVE, NVD) and change in any order, so the
4
+ status is recomputed from scratch every time (PHASE0_PLAN §3).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from collections.abc import Mapping
12
+
13
+ from limbowatch.ecosystems import reviewable
14
+ from limbowatch.models import GITHUB_CNA, Assessment, CveLabel, Facts, RepoAdvisory, Status
15
+
16
+
17
+ def status(f: Facts) -> Status:
18
+ if f.reviewed or f.in_osv:
19
+ return Status.REVIEWED
20
+ if f.repo_state == "withdrawn":
21
+ return Status.WITHDRAWN
22
+ if f.repo_state != "published":
23
+ return Status.NOT_PUBLISHED
24
+ if f.duplicate_of:
25
+ return Status.DUPLICATE
26
+ return Status.LIMBO
27
+
28
+
29
+ def cve_label(f: Facts) -> CveLabel:
30
+ if f.cited_cve:
31
+ if f.cited_cve_state == "PUBLISHED":
32
+ return CveLabel.PUBLISHED_CITED
33
+ if f.cited_cve_state == "RESERVED":
34
+ return CveLabel.RESERVED
35
+ return CveLabel.OTHER
36
+ if f.uncited_cves:
37
+ return CveLabel.PUBLISHED_UNCITED
38
+ return CveLabel.NONE
39
+
40
+
41
+ def assess(adv: RepoAdvisory, f: Facts) -> Assessment:
42
+ ecos = tuple(sorted({p.ecosystem for p in adv.packages} or {"other"}))
43
+ return Assessment(
44
+ ghsa_id=adv.ghsa_id,
45
+ status=status(f),
46
+ cve_label=cve_label(f),
47
+ likely_permanent=not any(reviewable(e) for e in ecos),
48
+ ecosystems=ecos,
49
+ )
50
+
51
+
52
+ def facts_hash(f: Facts) -> str:
53
+ return hashlib.sha256(json.dumps(f.model_dump(mode="json"), sort_keys=True).encode()).hexdigest()
54
+
55
+
56
+ def transitions(old: Facts | None, new: Facts, cnas: Mapping[str, str | None] | None = None) -> list[str]:
57
+ """Human-readable events between two fact snapshots (empty if nothing meaningful changed).
58
+
59
+ `cnas` maps uncited CVE IDs to their CNA, so GitHub's own CVEs aren't called another CNA's (F-067)."""
60
+ if old is None:
61
+ return ["first seen"]
62
+ ev = []
63
+ if status(old) != status(new):
64
+ ev.append(f"{status(old).value} -> {status(new).value}")
65
+ if old.cited_cve != new.cited_cve:
66
+ ev.append(f"cites {new.cited_cve or 'no CVE'} (was {old.cited_cve or 'none'})")
67
+ elif old.cited_cve_state != new.cited_cve_state and new.cited_cve:
68
+ ev.append(f"{new.cited_cve} {old.cited_cve_state or '?'} -> {new.cited_cve_state}")
69
+ added = sorted(set(new.uncited_cves) - set(old.uncited_cves))
70
+ github = [c for c in added if (cnas or {}).get(c) == GITHUB_CNA]
71
+ if github:
72
+ ev.append(f"new CVE from GitHub's CNA, not cited: {', '.join(github)}")
73
+ if other := [c for c in added if c not in github]:
74
+ ev.append(f"new CVE from another CNA: {', '.join(other)}")
75
+ if not old.duplicate_of and new.duplicate_of:
76
+ ev.append(f"duplicate of reviewed {', '.join(new.duplicate_of)}")
77
+ return ev
@@ -0,0 +1,18 @@
1
+ """Spreadsheet-safe CSV cells. Pure.
2
+
3
+ Spreadsheets treat a cell starting with = + - @ as a formula. Advisory fields are typed by maintainers, so a
4
+ cell starting with = gets a leading ', and so does one starting with + - @ that could call anything (a function,
5
+ a link, DDE). npm scopes like `@scope/pkg; other` and numbers like -1 stay as entered.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _CALLS = re.compile(r"[()|!\"'&\[\]{}$\t\r\n]") # what a formula needs to call a function, a link or DDE
13
+
14
+
15
+ def cell(v: str) -> str:
16
+ if v[:1] in ("=", "\t", "\r") or (v[:1] in ("+", "-", "@") and _CALLS.search(v)):
17
+ return f"'{v}"
18
+ return v
@@ -0,0 +1,123 @@
1
+ """Ecosystem registry: GHSA ecosystem name -> OSV ecosystem, purl type, review support.
2
+
3
+ Adding an ecosystem means adding one entry here (and, for version evaluation, a comparator in
4
+ versions.py). Everything else looks ecosystems up through `info()`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EcosystemInfo:
15
+ ghsa: str # GHSA / GitHub Advisory Database ecosystem name
16
+ osv: str | None # OSV ecosystem name (None: OSV has no equivalent)
17
+ purl_type: str | None
18
+ reviewable: bool # GitHub reviews advisories for this ecosystem (FINDINGS F-015, F-023)
19
+ name_rule: str = r".+" # what a well-formed package name looks like, after cleaning
20
+ name_style: str = "" # "pep503" / "lower": the canonical spelling scanners compare; "url": a path, not a URL
21
+ depsdev: str | None = None # deps.dev system, for mapping a package to its source repo
22
+
23
+
24
+ _REGISTRY: dict[str, EcosystemInfo] = {
25
+ e.ghsa: e
26
+ for e in (
27
+ # Name rules follow each registry's documented naming (FINDINGS F-069)
28
+ EcosystemInfo("npm", "npm", "npm", True, r"(?:@[A-Za-z0-9~-][\w.~-]*/)?[A-Za-z0-9~-][\w.~-]*", depsdev="npm"),
29
+ EcosystemInfo("pip", "PyPI", "pypi", True, r"[a-z0-9]+(?:-[a-z0-9]+)*", "pep503", depsdev="pypi"),
30
+ EcosystemInfo(
31
+ "go", "Go", "golang", True, r"stdlib|toolchain|[a-z0-9.-]+\.[a-z]{2,}(?:/[\w.~+-]+)*", "url", depsdev="go"
32
+ ),
33
+ EcosystemInfo("rust", "crates.io", "cargo", True, r"[A-Za-z0-9_-]+", depsdev="cargo"),
34
+ EcosystemInfo("maven", "Maven", "maven", True, r"[\w.-]+:[\w.-]+", depsdev="maven"),
35
+ EcosystemInfo("nuget", "NuGet", "nuget", True, r"[\w.-]+", depsdev="nuget"),
36
+ EcosystemInfo(
37
+ "composer",
38
+ "Packagist",
39
+ "composer",
40
+ True,
41
+ r"[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*",
42
+ "lower",
43
+ ),
44
+ EcosystemInfo("rubygems", "RubyGems", "gem", True, r"[\w.-]+", depsdev="rubygems"),
45
+ EcosystemInfo("erlang", "Hex", "hex", True, r"[a-z][a-z0-9_]*"),
46
+ EcosystemInfo("actions", "GitHub Actions", "githubactions", True, r"[\w.-]+/[\w.-]+(?:/[\w./-]+)?", "url"),
47
+ EcosystemInfo("pub", "Pub", "pub", True, r"[a-z_][a-z0-9_]*"),
48
+ EcosystemInfo("swift", "SwiftURL", "swift", True, r"[a-z0-9.-]+\.[a-z]{2,}(?:/[\w.-]+){2,}", "url"),
49
+ )
50
+ }
51
+
52
+
53
+ def info(ghsa_ecosystem: str | None) -> EcosystemInfo:
54
+ """Registry entry; unknown, empty or free-text ecosystems are not reviewable."""
55
+ key = (ghsa_ecosystem or "").strip().lower()
56
+ return _REGISTRY.get(key) or EcosystemInfo(key or "other", None, None, False)
57
+
58
+
59
+ def reviewable(ghsa_ecosystem: str | None) -> bool:
60
+ return info(ghsa_ecosystem).reviewable
61
+
62
+
63
+ _BY_OSV = {e.osv: e for e in _REGISTRY.values() if e.osv}
64
+ _BY_PURL = {e.purl_type: e for e in _REGISTRY.values() if e.purl_type}
65
+
66
+
67
+ def by_osv(osv_ecosystem: str) -> EcosystemInfo | None:
68
+ """Registry entry for an OSV ecosystem name such as "PyPI" or "crates.io" (or "Debian:12")."""
69
+ return _BY_OSV.get(osv_ecosystem.split(":", 1)[0])
70
+
71
+
72
+ def by_purl_type(purl_type: str) -> EcosystemInfo | None:
73
+ return _BY_PURL.get(purl_type.lower())
74
+
75
+
76
+ # OSV's ecosystem list (https://osv-vulnerabilities.storage.googleapis.com/ecosystems.txt, 2026-10-31).
77
+ # OSV-Scanner's offline mode fails the whole scan when an ecosystem it needs has no zip, so the feed
78
+ # serves one for each, and the watcher's inventory step uses an empty one for each (F-074, F-078).
79
+ OSV_ECOSYSTEMS = (
80
+ "AlmaLinux", "Alpaquita", "Alpine", "Android", "Azure Linux", "BellSoft Hardened Containers", "Bitnami", "CRAN",
81
+ "Chainguard", "CleanStart", "Debian", "Echo", "GHC", "GIT", "GSD", "GitHub Actions", "Go", "Hackage", "Hex",
82
+ "Julia", "Linux", "Mageia", "Maven", "MinimOS", "NuGet", "OSS-Fuzz", "Packagist", "Pub", "PyPI", "Red Hat",
83
+ "Red Hat Lightwell", "Rocky Linux", "Root", "RubyGems", "SUSE", "SwiftURL", "TuxCare", "UVI", "Ubuntu", "VSCode",
84
+ "Wolfi", "crates.io", "npm", "opam", "openEuler", "openSUSE",
85
+ ) # fmt: skip
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class PackageName:
90
+ name: str # the name to publish and match on
91
+ raw: str # as the maintainer entered it
92
+ valid: bool # well-formed for its ecosystem; an invalid name can't match any real package
93
+
94
+ @property
95
+ def cleaned(self) -> bool:
96
+ return self.name != self.raw
97
+
98
+
99
+ _MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
100
+
101
+
102
+ def package_name(ghsa_ecosystem: str | None, raw: str | None) -> PackageName | None:
103
+ """A maintainer-entered package name, cleaned only where the result is certain (FINDINGS F-069).
104
+
105
+ Scanners compare names exactly (PyPI and Composer after canonicalizing their own side), so a
106
+ stray space, a URL or the wrong case means the advisory silently never matches. Names that still
107
+ don't fit the ecosystem's naming rule are flagged, never guessed at.
108
+ """
109
+ if raw is None or not raw.strip():
110
+ return None
111
+ e = info(ghsa_ecosystem)
112
+ s = _MD_LINK.sub(r"\1", raw).strip().strip("`'\"").strip()
113
+ if e.name_style == "url":
114
+ s = re.sub(r"^(?:git\+)?https?://", "", s).removesuffix("/").removesuffix(".git")
115
+ if e.ghsa == "actions":
116
+ s = s.removeprefix("github.com/")
117
+ elif e.name_style == "pep503":
118
+ if m := re.search(r"#egg=([\w.-]+)", s):
119
+ s = m.group(1)
120
+ s = re.sub(r"[-_.]+", "-", s).lower()
121
+ elif e.name_style == "lower":
122
+ s = s.lower()
123
+ return PackageName(s, raw, re.fullmatch(e.name_rule, s) is not None)
@@ -0,0 +1,229 @@
1
+ """The only module that builds HTTP requests.
2
+
3
+ - Per-host concurrency limits, retries with exponential backoff, Retry-After.
4
+ - GitHub primary and secondary rate limits; a request budget for the run (304s are free and
5
+ don't count, FINDINGS F-058).
6
+ - Conditional requests: ETags are only valid for the token that obtained them (the response
7
+ varies by Authorization), so the client exposes a token fingerprint to store alongside them.
8
+ - The token is never logged, printed or returned.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import logging
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+ from urllib.parse import urlsplit
20
+
21
+ import httpx
22
+
23
+ from limbowatch import __version__
24
+
25
+ log = logging.getLogger(__name__)
26
+ USER_AGENT = f"limbowatch/{__version__} (+https://limbo.watch)"
27
+ GITHUB_API = "api.github.com"
28
+ HOST_CONCURRENCY = {
29
+ GITHUB_API: 8,
30
+ "api.osv.dev": 16,
31
+ "cveawg.mitre.org": 16,
32
+ "services.nvd.nist.gov": 1,
33
+ "raw.githubusercontent.com": 8,
34
+ "api.deps.dev": 16,
35
+ "rubygems.org": 4, # its API asks for at most about 10 requests a second
36
+ }
37
+
38
+
39
+ class BudgetExhausted(Exception):
40
+ """The run's GitHub request budget (or the primary rate limit) is used up."""
41
+
42
+
43
+ class DeadlineReached(Exception):
44
+ """The run's wall-clock deadline has passed."""
45
+
46
+
47
+ @dataclass
48
+ class Response:
49
+ status: int
50
+ body: Any
51
+ etag: str | None = None
52
+ link: str | None = None
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ return self.status == 200
57
+
58
+
59
+ @dataclass
60
+ class Stats:
61
+ requests: dict[str, int] = field(default_factory=dict)
62
+ github_counted: int = 0
63
+ github_not_modified: int = 0
64
+ failures: int = 0
65
+
66
+
67
+ class HttpClient:
68
+ def __init__(
69
+ self,
70
+ github_token: str | None = None,
71
+ *,
72
+ github_budget: int | None = None,
73
+ deadline: float | None = None,
74
+ nvd_api_key: str | None = None,
75
+ max_rate_limit_wait: float = 900.0,
76
+ transport: httpx.AsyncBaseTransport | None = None,
77
+ sleep: Any = asyncio.sleep,
78
+ ) -> None:
79
+ self._token = github_token
80
+ self.token_fingerprint = hashlib.sha256(github_token.encode()).hexdigest()[:16] if github_token else "anonymous"
81
+ self.github_budget = github_budget
82
+ self.deadline = deadline
83
+ self._nvd_key = nvd_api_key
84
+ self._nvd_spacing = 0.7 if nvd_api_key else 6.5
85
+ self._nvd_last = 0.0
86
+ self._max_wait = max_rate_limit_wait
87
+ self._sleep = sleep
88
+ self._sems = {h: asyncio.Semaphore(n) for h, n in HOST_CONCURRENCY.items()}
89
+ self._default_sem = asyncio.Semaphore(8)
90
+ self._nvd_lock = asyncio.Lock()
91
+ self.stats = Stats()
92
+ self._client = httpx.AsyncClient(
93
+ timeout=60, follow_redirects=True, headers={"User-Agent": USER_AGENT}, transport=transport
94
+ )
95
+
96
+ async def __aenter__(self) -> HttpClient:
97
+ return self
98
+
99
+ async def __aexit__(self, *exc: object) -> None:
100
+ await self._client.aclose()
101
+
102
+ def budget_left(self) -> int | None:
103
+ return None if self.github_budget is None else self.github_budget - self.stats.github_counted
104
+
105
+ def _check_deadline(self) -> None:
106
+ if self.deadline is not None and time.monotonic() > self.deadline:
107
+ raise DeadlineReached
108
+
109
+ async def get(self, url: str, *, etag: str | None = None, json_body: Any = None, raw: bool = False) -> Response:
110
+ """GET (or POST with `json_body`). Returns status 304 with no body when `etag` still matches.
111
+
112
+ With `raw`, the body is the response bytes (e.g. a zip), and `etag` is sent to any host.
113
+ """
114
+ self._check_deadline()
115
+ host = urlsplit(url).hostname or ""
116
+ is_gh = host == GITHUB_API
117
+ if is_gh and (left := self.budget_left()) is not None and left <= 0:
118
+ raise BudgetExhausted
119
+ headers: dict[str, str] = {}
120
+ if is_gh:
121
+ headers.update({"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"})
122
+ if self._token:
123
+ headers["Authorization"] = f"Bearer {self._token}"
124
+ if etag:
125
+ headers["If-None-Match"] = etag
126
+ elif raw and etag:
127
+ headers["If-None-Match"] = etag
128
+ if host == "services.nvd.nist.gov":
129
+ if self._nvd_key:
130
+ headers["apiKey"] = self._nvd_key
131
+ async with self._nvd_lock: # NVD: one request per spacing interval
132
+ wait = self._nvd_last + self._nvd_spacing - time.monotonic()
133
+ if wait > 0:
134
+ await self._sleep(wait)
135
+ self._nvd_last = time.monotonic()
136
+ sem = self._sems.get(host, self._default_sem)
137
+ async with sem:
138
+ r = await self._send(url, headers, json_body, is_gh)
139
+ self.stats.requests[host] = self.stats.requests.get(host, 0) + 1
140
+ if r is None:
141
+ self.stats.failures += 1
142
+ return Response(-1, None)
143
+ if is_gh:
144
+ if r.status_code == 304:
145
+ self.stats.github_not_modified += 1
146
+ else:
147
+ self.stats.github_counted += 1
148
+ if r.status_code == 304:
149
+ return Response(304, None, etag=etag)
150
+ if raw:
151
+ return Response(r.status_code, r.content, etag=r.headers.get("etag"), link=r.headers.get("link"))
152
+ try:
153
+ body: Any = r.json()
154
+ except ValueError:
155
+ body = r.text
156
+ return Response(r.status_code, body, etag=r.headers.get("etag"), link=r.headers.get("link"))
157
+
158
+ async def request(
159
+ self,
160
+ method: str,
161
+ url: str,
162
+ *,
163
+ auth: tuple[str, str],
164
+ json_body: Any = None,
165
+ params: dict[str, Any] | None = None,
166
+ ) -> Response:
167
+ """A request to an issue tracker (GitLab, Gitea): any method, a caller-supplied auth header.
168
+
169
+ The header value is a token; it's sent only to `url`'s host and never logged.
170
+ """
171
+ self._check_deadline()
172
+ host = urlsplit(url).hostname or ""
173
+ r: httpx.Response | None = None
174
+ async with self._sems.get(host, self._default_sem):
175
+ for attempt in range(5):
176
+ try:
177
+ r = await self._client.request(method, url, headers={auth[0]: auth[1]}, json=json_body,
178
+ params=params) # fmt: skip
179
+ except httpx.HTTPError as e:
180
+ log.warning("http error", extra={"host": host, "error": type(e).__name__, "attempt": attempt})
181
+ await self._sleep(min(30, 2**attempt))
182
+ continue
183
+ if r.status_code in (429, 502, 503, 504):
184
+ await self._sleep(min(60, int(r.headers.get("retry-after", 2 ** (attempt + 1)))))
185
+ continue
186
+ break
187
+ self.stats.requests[host] = self.stats.requests.get(host, 0) + 1
188
+ if r is None:
189
+ self.stats.failures += 1
190
+ return Response(-1, None)
191
+ try:
192
+ body: Any = r.json()
193
+ except ValueError:
194
+ body = r.text
195
+ return Response(r.status_code, body, etag=r.headers.get("etag"), link=r.headers.get("link"))
196
+
197
+ async def _send(self, url: str, headers: dict[str, str], json_body: Any, is_gh: bool) -> httpx.Response | None:
198
+ for attempt in range(6):
199
+ self._check_deadline()
200
+ try:
201
+ if json_body is None:
202
+ r = await self._client.get(url, headers=headers)
203
+ else:
204
+ r = await self._client.post(url, headers=headers, json=json_body)
205
+ except httpx.HTTPError as e:
206
+ log.warning("http error", extra={"url": url, "error": type(e).__name__, "attempt": attempt})
207
+ await self._sleep(min(60, 2**attempt))
208
+ continue
209
+ if is_gh and r.status_code in (403, 429):
210
+ if r.headers.get("x-ratelimit-remaining") == "0":
211
+ await self._wait_for_reset(r)
212
+ continue
213
+ if "retry-after" in r.headers or "secondary rate limit" in r.text.lower():
214
+ await self._sleep(int(r.headers.get("retry-after", 60)) + 1)
215
+ continue
216
+ if r.status_code in (429, 500, 502, 503, 504):
217
+ await self._sleep(min(60, int(r.headers.get("retry-after", 2 ** (attempt + 1)))))
218
+ continue
219
+ if is_gh and int(r.headers.get("x-ratelimit-remaining", "1000")) < 20:
220
+ await self._wait_for_reset(r)
221
+ return r
222
+ return None
223
+
224
+ async def _wait_for_reset(self, r: httpx.Response) -> None:
225
+ wait = max(5.0, float(r.headers.get("x-ratelimit-reset", time.time() + 60)) - time.time() + 5)
226
+ if wait > self._max_wait:
227
+ raise BudgetExhausted
228
+ log.info("github rate limit low; waiting", extra={"seconds": round(wait)})
229
+ await self._sleep(wait)
@@ -0,0 +1,5 @@
1
+ """The watcher's inventory: what a project depends on, and what can't be checked (Phase 4).
2
+
3
+ Two backends: OSV-Scanner over a source tree (`osv_scanner`), or an SBOM (`sbom`). Both report every
4
+ dependency they can't check as an `UnresolvedDependency` with a reason; nothing is dropped.
5
+ """