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.
- limbowatch-0.1.0a1/.gitignore +29 -0
- limbowatch-0.1.0a1/PKG-INFO +40 -0
- limbowatch-0.1.0a1/README.md +7 -0
- limbowatch-0.1.0a1/pyproject.toml +45 -0
- limbowatch-0.1.0a1/src/limbowatch/__init__.py +3 -0
- limbowatch-0.1.0a1/src/limbowatch/classify.py +77 -0
- limbowatch-0.1.0a1/src/limbowatch/csvsafe.py +18 -0
- limbowatch-0.1.0a1/src/limbowatch/ecosystems.py +123 -0
- limbowatch-0.1.0a1/src/limbowatch/http.py +229 -0
- limbowatch-0.1.0a1/src/limbowatch/inventory/__init__.py +5 -0
- limbowatch-0.1.0a1/src/limbowatch/inventory/checks.py +161 -0
- limbowatch-0.1.0a1/src/limbowatch/inventory/osv_scanner.py +219 -0
- limbowatch-0.1.0a1/src/limbowatch/inventory/purl.py +50 -0
- limbowatch-0.1.0a1/src/limbowatch/inventory/sbom.py +67 -0
- limbowatch-0.1.0a1/src/limbowatch/models.py +178 -0
- limbowatch-0.1.0a1/src/limbowatch/normalize.py +101 -0
- limbowatch-0.1.0a1/src/limbowatch/publish/__init__.py +227 -0
- limbowatch-0.1.0a1/src/limbowatch/publish/feed.py +165 -0
- limbowatch-0.1.0a1/src/limbowatch/publish/quality.py +153 -0
- limbowatch-0.1.0a1/src/limbowatch/publish/report.py +242 -0
- limbowatch-0.1.0a1/src/limbowatch/publish/site.py +1866 -0
- limbowatch-0.1.0a1/src/limbowatch/py.typed +0 -0
- limbowatch-0.1.0a1/src/limbowatch/schedule.py +60 -0
- limbowatch-0.1.0a1/src/limbowatch/sources/__init__.py +1 -0
- limbowatch-0.1.0a1/src/limbowatch/sources/cve.py +79 -0
- limbowatch-0.1.0a1/src/limbowatch/sources/github.py +61 -0
- limbowatch-0.1.0a1/src/limbowatch/sources/nvd.py +29 -0
- limbowatch-0.1.0a1/src/limbowatch/sources/osv.py +25 -0
- limbowatch-0.1.0a1/src/limbowatch/store.py +160 -0
- limbowatch-0.1.0a1/src/limbowatch/tracker.py +429 -0
- limbowatch-0.1.0a1/src/limbowatch/versions.py +326 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/__init__.py +1 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/candidates.py +82 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/issues.py +209 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/mapping.py +88 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/match.py +152 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/output.py +788 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/scan.py +114 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/state.py +108 -0
- limbowatch-0.1.0a1/src/limbowatch/watch/trackers.py +203 -0
- 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,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
|
+
"""
|