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 ADDED
@@ -0,0 +1,3 @@
1
+ """Limbowatch: track published security advisories stuck in limbo."""
2
+
3
+ __version__ = "0.1.0a1"
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
@@ -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))