limbowatch 0.1.0a1__py3-none-any.whl
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/__init__.py +3 -0
- limbowatch/classify.py +77 -0
- limbowatch/csvsafe.py +18 -0
- limbowatch/ecosystems.py +123 -0
- limbowatch/http.py +229 -0
- limbowatch/inventory/__init__.py +5 -0
- limbowatch/inventory/checks.py +161 -0
- limbowatch/inventory/osv_scanner.py +219 -0
- limbowatch/inventory/purl.py +50 -0
- limbowatch/inventory/sbom.py +67 -0
- limbowatch/models.py +178 -0
- limbowatch/normalize.py +101 -0
- limbowatch/publish/__init__.py +227 -0
- limbowatch/publish/feed.py +165 -0
- limbowatch/publish/quality.py +153 -0
- limbowatch/publish/report.py +242 -0
- limbowatch/publish/site.py +1866 -0
- limbowatch/py.typed +0 -0
- limbowatch/schedule.py +60 -0
- limbowatch/sources/__init__.py +1 -0
- limbowatch/sources/cve.py +79 -0
- limbowatch/sources/github.py +61 -0
- limbowatch/sources/nvd.py +29 -0
- limbowatch/sources/osv.py +25 -0
- limbowatch/store.py +160 -0
- limbowatch/tracker.py +429 -0
- limbowatch/versions.py +326 -0
- limbowatch/watch/__init__.py +1 -0
- limbowatch/watch/candidates.py +82 -0
- limbowatch/watch/issues.py +209 -0
- limbowatch/watch/mapping.py +88 -0
- limbowatch/watch/match.py +152 -0
- limbowatch/watch/output.py +788 -0
- limbowatch/watch/scan.py +114 -0
- limbowatch/watch/state.py +108 -0
- limbowatch/watch/trackers.py +203 -0
- limbowatch/watch/upstream.py +58 -0
- limbowatch-0.1.0a1.dist-info/METADATA +40 -0
- limbowatch-0.1.0a1.dist-info/RECORD +40 -0
- limbowatch-0.1.0a1.dist-info/WHEEL +4 -0
limbowatch/__init__.py
ADDED
limbowatch/classify.py
ADDED
|
@@ -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
|
limbowatch/csvsafe.py
ADDED
|
@@ -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
|
limbowatch/ecosystems.py
ADDED
|
@@ -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)
|
limbowatch/http.py
ADDED
|
@@ -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
|
+
"""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Re-reading lockfiles for what OSV-Scanner's inventory gets wrong without saying so (FINDINGS F-078).
|
|
2
|
+
|
|
3
|
+
Pure functions over file text: each returns what one kind of file says about how a package was
|
|
4
|
+
installed, so the inventory can report a dependency as unchecked instead of matching a wrong version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
PINNED, RANGE, VCS, PATH = "pinned", "range", "vcs", "path"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Requirement:
|
|
19
|
+
name: str # PEP 503 normalized; "" when a VCS or path line doesn't say
|
|
20
|
+
kind: str # PINNED, RANGE, VCS or PATH
|
|
21
|
+
spec: str # the line as written, for the report
|
|
22
|
+
line: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pep503(name: str) -> str:
|
|
26
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def requirements(text: str) -> list[Requirement]:
|
|
30
|
+
"""Each requirement line of a pip requirements file, classified by how exactly it pins a version."""
|
|
31
|
+
out: list[Requirement] = []
|
|
32
|
+
joined: list[tuple[int, str]] = []
|
|
33
|
+
buf, start = "", 0
|
|
34
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
35
|
+
if not buf:
|
|
36
|
+
start = n
|
|
37
|
+
if raw.rstrip().endswith("\\"):
|
|
38
|
+
buf += raw.rstrip()[:-1] + " "
|
|
39
|
+
continue
|
|
40
|
+
joined.append((start, buf + raw))
|
|
41
|
+
buf = ""
|
|
42
|
+
if buf:
|
|
43
|
+
joined.append((start, buf))
|
|
44
|
+
for n, raw in joined:
|
|
45
|
+
line = re.sub(r"(^|\s)#.*$", "", raw).strip() # a comment, but not "#egg=" inside a URL
|
|
46
|
+
if not line:
|
|
47
|
+
continue
|
|
48
|
+
editable = re.match(r"(?:-e|--editable)[\s=]+(.+)", line)
|
|
49
|
+
if editable:
|
|
50
|
+
target = editable.group(1).strip()
|
|
51
|
+
kind = VCS if "://" in target or target.startswith("git+") else PATH
|
|
52
|
+
out.append(Requirement(_egg(target), kind, line, n))
|
|
53
|
+
continue
|
|
54
|
+
if line.startswith("-"):
|
|
55
|
+
continue # -r, -c, --index-url, --hash and other options
|
|
56
|
+
if re.match(r"(?:[a-z][a-z0-9+.-]*\+)?[a-z]+://", line, re.I) or " @ " in line or line.startswith("git+"):
|
|
57
|
+
name = re.match(r"([A-Za-z0-9][A-Za-z0-9._-]*)\s*@", line)
|
|
58
|
+
target = line.split("@", 1)[-1] if name else line
|
|
59
|
+
kind = PATH if target.strip().startswith("file:") else VCS
|
|
60
|
+
out.append(Requirement(pep503(name.group(1)) if name else _egg(line), kind, line, n))
|
|
61
|
+
continue
|
|
62
|
+
if line.startswith((".", "/")) or re.search(r"\.(whl|tar\.gz|zip)$", line):
|
|
63
|
+
out.append(Requirement(_egg(line), PATH, line, n))
|
|
64
|
+
continue
|
|
65
|
+
m = re.match(r"([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([^;]*)", line)
|
|
66
|
+
if not m:
|
|
67
|
+
continue
|
|
68
|
+
spec = re.sub(r"\s*--[\w-]+(?:[=\s]+\S+)?", "", m.group(2)).strip() # drop --hash and other options
|
|
69
|
+
pinned = re.fullmatch(r"===?\s*[^,*\s]+", spec) is not None
|
|
70
|
+
out.append(Requirement(pep503(m.group(1)), PINNED if pinned else RANGE, line, n))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _egg(target: str) -> str:
|
|
75
|
+
m = re.search(r"#egg=([A-Za-z0-9][A-Za-z0-9._-]*)", target)
|
|
76
|
+
return pep503(m.group(1)) if m else ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cargo_sources(text: str) -> dict[tuple[str, str], str]:
|
|
80
|
+
"""(name, version) -> "registry", "vcs" or "path", from a Cargo.lock. Local crates have no `source`."""
|
|
81
|
+
try:
|
|
82
|
+
data = tomllib.loads(text)
|
|
83
|
+
except tomllib.TOMLDecodeError:
|
|
84
|
+
return {}
|
|
85
|
+
out = {}
|
|
86
|
+
for p in data.get("package", []):
|
|
87
|
+
src = str(p.get("source") or "")
|
|
88
|
+
kind = "registry" if src.startswith(("registry+", "sparse+")) else VCS if src.startswith("git+") else PATH
|
|
89
|
+
out[(str(p.get("name", "")), str(p.get("version", "")))] = kind
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def uv_sources(text: str) -> dict[str, str]:
|
|
94
|
+
"""name -> "registry", "vcs" or "path", from a uv.lock. The project itself is `editable` or `virtual`."""
|
|
95
|
+
try:
|
|
96
|
+
data = tomllib.loads(text)
|
|
97
|
+
except tomllib.TOMLDecodeError:
|
|
98
|
+
return {}
|
|
99
|
+
out = {}
|
|
100
|
+
for p in data.get("package", []):
|
|
101
|
+
src = p.get("source") or {}
|
|
102
|
+
kind = "registry" if "registry" in src else VCS if "git" in src or "url" in src else PATH
|
|
103
|
+
out[str(p.get("name", ""))] = kind
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def npm_registry_without_resolved(text: str) -> set[tuple[str, str]]:
|
|
108
|
+
"""(name, version) of package-lock entries that are registry packages despite having no `resolved`.
|
|
109
|
+
|
|
110
|
+
npm leaves `resolved` out under `omit-lockfile-registry-resolved`; OSV-Scanner 2.7.0 then treats
|
|
111
|
+
the package as local and doesn't check it (osv-scanner#3177, FINDINGS F-078). A real local
|
|
112
|
+
package is a link, or resolves to a path.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(text)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return set()
|
|
118
|
+
out = set()
|
|
119
|
+
for key, entry in (data.get("packages") or {}).items():
|
|
120
|
+
if not key.startswith("node_modules/") and "/node_modules/" not in key:
|
|
121
|
+
continue
|
|
122
|
+
if not isinstance(entry, dict) or entry.get("link") or "resolved" in entry or "version" not in entry:
|
|
123
|
+
continue
|
|
124
|
+
name = entry.get("name") or key.rsplit("node_modules/", 1)[-1]
|
|
125
|
+
out.add((str(name), str(entry["version"])))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def locate(text: str, filename: str, name: str, version: str = "") -> int:
|
|
130
|
+
"""The 1-based line that declares `name` in a lockfile or manifest, or 1 if it can't be found.
|
|
131
|
+
|
|
132
|
+
Code Quality and SARIF alerts need a line; OSV-Scanner doesn't report one (FINDINGS F-078, F-079).
|
|
133
|
+
"""
|
|
134
|
+
lines = text.splitlines()
|
|
135
|
+
patterns = []
|
|
136
|
+
base = filename.rsplit("/", 1)[-1]
|
|
137
|
+
if base in ("package-lock.json", "npm-shrinkwrap.json"):
|
|
138
|
+
patterns.append(re.compile(rf'"(?:[^"]*/)?node_modules/{re.escape(name)}"'))
|
|
139
|
+
if base == "Cargo.lock":
|
|
140
|
+
patterns.append(re.compile(rf'^name = "{re.escape(name)}"$'))
|
|
141
|
+
if base.endswith(".txt"):
|
|
142
|
+
patterns.append(re.compile(rf"^\s*{_pep503_pattern(name)}\s*(?:[\[=<>!~;@ ]|$)", re.I))
|
|
143
|
+
patterns.append(re.compile(rf"(?<![\w./-]){re.escape(name)}(?![\w-])"))
|
|
144
|
+
if "/" in name: # e.g. a Git URL: the lockfile names the package by its last path segment
|
|
145
|
+
tail = re.sub(r"\.git$", "", name.rstrip("/").rsplit("/", 1)[-1])
|
|
146
|
+
patterns.append(
|
|
147
|
+
re.compile(rf'"(?:[^"]*/)?node_modules/{re.escape(tail)}"|(?<![\w.-]){re.escape(tail)}(?![\w-])')
|
|
148
|
+
)
|
|
149
|
+
for pat in patterns:
|
|
150
|
+
hits = [i for i, ln in enumerate(lines, 1) if pat.search(ln)]
|
|
151
|
+
if not hits:
|
|
152
|
+
continue
|
|
153
|
+
if version and len(hits) > 1:
|
|
154
|
+
near = [i for i in hits if any(version in ln for ln in lines[i - 1 : i + 3])]
|
|
155
|
+
hits = near or hits
|
|
156
|
+
return hits[0]
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pep503_pattern(name: str) -> str:
|
|
161
|
+
return "[-_.]+".join(re.escape(part) for part in re.split(r"[-_.]+", name))
|