ossiq 0.1.0__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.
- ossiq/__init__.py +4 -0
- ossiq/__main__.py +4 -0
- ossiq/adapters/__init__.py +0 -0
- ossiq/adapters/api.py +47 -0
- ossiq/adapters/api_github.py +295 -0
- ossiq/adapters/api_interfaces.py +138 -0
- ossiq/adapters/api_npm.py +195 -0
- ossiq/adapters/api_osv.py +98 -0
- ossiq/adapters/api_pypi.py +274 -0
- ossiq/adapters/detectors.py +19 -0
- ossiq/adapters/package_managers/__init__.py +0 -0
- ossiq/adapters/package_managers/api.py +32 -0
- ossiq/adapters/package_managers/api_npm.py +217 -0
- ossiq/adapters/package_managers/api_pip.py +251 -0
- ossiq/adapters/package_managers/api_pip_classic.py +184 -0
- ossiq/adapters/package_managers/api_uv.py +172 -0
- ossiq/adapters/package_managers/utils.py +25 -0
- ossiq/cli.py +143 -0
- ossiq/commands/__init__.py +0 -0
- ossiq/commands/export.py +68 -0
- ossiq/commands/scan.py +71 -0
- ossiq/domain/__init__.py +0 -0
- ossiq/domain/common.py +99 -0
- ossiq/domain/cve.py +40 -0
- ossiq/domain/exceptions.py +40 -0
- ossiq/domain/package.py +77 -0
- ossiq/domain/packages_manager.py +107 -0
- ossiq/domain/project.py +90 -0
- ossiq/domain/repository.py +28 -0
- ossiq/domain/version.py +271 -0
- ossiq/messages.py +35 -0
- ossiq/service/__init__.py +0 -0
- ossiq/service/common/__init__.py +0 -0
- ossiq/service/common/package_versions.py +107 -0
- ossiq/service/project.py +166 -0
- ossiq/settings.py +39 -0
- ossiq/timeutil.py +59 -0
- ossiq/ui/__init__.py +0 -0
- ossiq/ui/html/__init__.py +0 -0
- ossiq/ui/html/filter_format_highlight_days.py +42 -0
- ossiq/ui/html/tag_versions_difference.py +44 -0
- ossiq/ui/html/template_environment.py +46 -0
- ossiq/ui/interfaces.py +59 -0
- ossiq/ui/registry.py +72 -0
- ossiq/ui/renderers/__init__.py +0 -0
- ossiq/ui/renderers/export/__init__.py +1 -0
- ossiq/ui/renderers/export/csv.py +469 -0
- ossiq/ui/renderers/export/csv_datapackage.py +172 -0
- ossiq/ui/renderers/export/csv_schema_registry.py +220 -0
- ossiq/ui/renderers/export/json.py +73 -0
- ossiq/ui/renderers/export/json_schema_registry.py +70 -0
- ossiq/ui/renderers/export/models.py +171 -0
- ossiq/ui/renderers/scan/__init__.py +1 -0
- ossiq/ui/renderers/scan/console.py +131 -0
- ossiq/ui/renderers/scan/html.py +65 -0
- ossiq/ui/system.py +67 -0
- ossiq/unit_of_work/__init__.py +0 -0
- ossiq/unit_of_work/core.py +43 -0
- ossiq/unit_of_work/uow_project.py +83 -0
- ossiq-0.1.0.dist-info/METADATA +849 -0
- ossiq-0.1.0.dist-info/RECORD +66 -0
- ossiq-0.1.0.dist-info/WHEEL +5 -0
- ossiq-0.1.0.dist-info/entry_points.txt +2 -0
- ossiq-0.1.0.dist-info/licenses/AUTHORS +8 -0
- ossiq-0.1.0.dist-info/licenses/LICENSE +661 -0
- ossiq-0.1.0.dist-info/top_level.txt +1 -0
ossiq/__init__.py
ADDED
ossiq/__main__.py
ADDED
|
File without changes
|
ossiq/adapters/api.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Factory to instantiate API clients
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from ossiq.adapters.api_npm import PackageRegistryApiNpm
|
|
6
|
+
from ossiq.adapters.api_osv import CveApiOsv
|
|
7
|
+
from ossiq.adapters.api_pypi import PackageRegistryApiPypi
|
|
8
|
+
from ossiq.domain.common import ProjectPackagesRegistry, RepositoryProvider
|
|
9
|
+
from ossiq.settings import Settings
|
|
10
|
+
|
|
11
|
+
from .api_github import SourceCodeProviderApiGithub
|
|
12
|
+
from .api_interfaces import AbstractCveDatabaseApi, AbstractPackageRegistryApi, AbstractSourceCodeProviderApi
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_source_code_provider(provider_type: RepositoryProvider, settings: Settings) -> AbstractSourceCodeProviderApi:
|
|
16
|
+
"""
|
|
17
|
+
Return source code provider (like Github) using factory and respective type
|
|
18
|
+
"""
|
|
19
|
+
if provider_type == RepositoryProvider.PROVIDER_GITHUB:
|
|
20
|
+
return SourceCodeProviderApiGithub(settings.github_token)
|
|
21
|
+
else:
|
|
22
|
+
raise ValueError(f"Unknown source code provider type: {provider_type}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_package_registry_api(
|
|
26
|
+
package_registry: ProjectPackagesRegistry, settings: Settings
|
|
27
|
+
) -> AbstractPackageRegistryApi:
|
|
28
|
+
"""
|
|
29
|
+
Create instance of a specific ecosystem's package registry API
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
if package_registry == ProjectPackagesRegistry.NPM:
|
|
33
|
+
return PackageRegistryApiNpm(settings)
|
|
34
|
+
elif package_registry == ProjectPackagesRegistry.PYPI:
|
|
35
|
+
return PackageRegistryApiPypi(settings)
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f"Unknown package registry: {package_registry}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_cve_database() -> AbstractCveDatabaseApi:
|
|
41
|
+
"""
|
|
42
|
+
Return CVE database (like osv.dev). The purpose is little different from
|
|
43
|
+
Source Code Provider or Packages Registry/Ecosystem: there might be
|
|
44
|
+
more than one CSV database. For externel clients it should look like a
|
|
45
|
+
single database instance still.
|
|
46
|
+
"""
|
|
47
|
+
return CveApiOsv()
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of SourceCodeApiClient for Github
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import itertools
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from ..domain.common import VERSION_DATA_SOURCE_GITHUB_RELEASES, VERSION_DATA_SOURCE_GITHUB_TAGS, RepositoryProvider
|
|
14
|
+
from ..domain.exceptions import GithubRateLimitError
|
|
15
|
+
from ..domain.repository import Repository
|
|
16
|
+
from ..domain.version import Commit, PackageVersion, RepositoryVersion, User, sort_versions
|
|
17
|
+
from .api_interfaces import AbstractSourceCodeProviderApi
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
GITHUB_API = "https://api.github.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SourceCodeProviderApiGithub(AbstractSourceCodeProviderApi):
|
|
25
|
+
"""
|
|
26
|
+
Implementation of SourceCodeApiClient for Github
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
repository_provider: RepositoryProvider = RepositoryProvider.PROVIDER_GITHUB
|
|
30
|
+
|
|
31
|
+
github_token: str | None
|
|
32
|
+
|
|
33
|
+
def __init__(self, github_token: str | None):
|
|
34
|
+
self.github_token = github_token
|
|
35
|
+
if not self.github_token:
|
|
36
|
+
# FIXME: pass warning
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return "<SourceCodeProviderApiGithub instance>"
|
|
41
|
+
|
|
42
|
+
def _extract_next_url(self, link_header: str):
|
|
43
|
+
"""
|
|
44
|
+
Parse header <https://api.github.com/repositories/47118129/tags?page=2>;
|
|
45
|
+
rel="next" and extract URL
|
|
46
|
+
"""
|
|
47
|
+
if link_header is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
match = re.search(r"<(.*?)>; rel=\"next\"", link_header)
|
|
51
|
+
if match:
|
|
52
|
+
return match.group(1)
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def _make_github_api_request(self, url: str, timeout: int = 15) -> tuple[str | None, dict]:
|
|
57
|
+
"""
|
|
58
|
+
Make a request to the GitHub API and properly handle pagination
|
|
59
|
+
"""
|
|
60
|
+
headers = {}
|
|
61
|
+
if self.github_token:
|
|
62
|
+
headers["Authorization"] = f"Bearer {self.github_token}"
|
|
63
|
+
|
|
64
|
+
response = requests.get(url, timeout=timeout, headers=headers)
|
|
65
|
+
|
|
66
|
+
# Basically let user know that we're done here with Github.
|
|
67
|
+
if response.status_code == 403:
|
|
68
|
+
remaining_rate_limit = response.headers.get("x-ratelimit-remaining", "N/A")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
reset_rate_limit_time = datetime.datetime.fromtimestamp(
|
|
72
|
+
int(response.headers.get("x-ratelimit-reset", "N/A"))
|
|
73
|
+
).isoformat()
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
reset_rate_limit_time = "N/A"
|
|
76
|
+
|
|
77
|
+
total_rate_limit = response.headers.get("x-ratelimit-limit", "N/A")
|
|
78
|
+
|
|
79
|
+
raise GithubRateLimitError(
|
|
80
|
+
remaining=remaining_rate_limit,
|
|
81
|
+
total=total_rate_limit,
|
|
82
|
+
reset_time=reset_rate_limit_time,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
|
|
87
|
+
return self._extract_next_url(response.headers.get("Link", None)), response.json()
|
|
88
|
+
|
|
89
|
+
def _paginate_github_api_request(self, url: str) -> Iterable[dict]:
|
|
90
|
+
"""
|
|
91
|
+
Paginate responses from Github API
|
|
92
|
+
"""
|
|
93
|
+
next_url = url
|
|
94
|
+
|
|
95
|
+
while next_url:
|
|
96
|
+
next_url, data = self._make_github_api_request(next_url)
|
|
97
|
+
yield from data
|
|
98
|
+
|
|
99
|
+
def _load_releases(self, repository: Repository, versions_set: set[str]) -> Iterable[RepositoryVersion]:
|
|
100
|
+
"""
|
|
101
|
+
Fetch releases from a GitHub repo that match the provided versions.
|
|
102
|
+
"""
|
|
103
|
+
url = f"{GITHUB_API}/repos/{repository.owner}/{repository.name}/releases"
|
|
104
|
+
|
|
105
|
+
n = 0
|
|
106
|
+
# NOTE: we need to pull all the releases we're interested in and then break iteration
|
|
107
|
+
for release in self._paginate_github_api_request(url):
|
|
108
|
+
normalized_tag = release["tag_name"]
|
|
109
|
+
if normalized_tag in versions_set:
|
|
110
|
+
yield RepositoryVersion(
|
|
111
|
+
version_source_type=VERSION_DATA_SOURCE_GITHUB_RELEASES,
|
|
112
|
+
version=normalized_tag,
|
|
113
|
+
ref_name=release["tag_name"],
|
|
114
|
+
release_name=release["name"],
|
|
115
|
+
release_notes=release.get("body", None),
|
|
116
|
+
source_url=release["html_url"],
|
|
117
|
+
patch_url=None,
|
|
118
|
+
commits=None,
|
|
119
|
+
ref_previous=None,
|
|
120
|
+
)
|
|
121
|
+
n += 1
|
|
122
|
+
|
|
123
|
+
if n == len(versions_set):
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
def _load_commits_between_refs(
|
|
127
|
+
self, repository: Repository, start_ref: str | None, end_ref: str | None
|
|
128
|
+
) -> tuple[str, list[Commit]] | tuple[None, None]:
|
|
129
|
+
"""
|
|
130
|
+
Load commits between two git references (tags/commits) and its patch URL.
|
|
131
|
+
|
|
132
|
+
NOTE: A potential optimization is to pull all commits between the oldest and
|
|
133
|
+
newest versions in one API call, then associate them with tags client-side.
|
|
134
|
+
This would reduce API calls but increase complexity.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
if start_ref or not end_ref:
|
|
138
|
+
return None, None
|
|
139
|
+
|
|
140
|
+
compare_url = f"{GITHUB_API}/repos/{repository.owner}/{repository.name}/compare/{start_ref}...{end_ref}"
|
|
141
|
+
|
|
142
|
+
_, compare_data = self._make_github_api_request(compare_url)
|
|
143
|
+
|
|
144
|
+
commits_raw = compare_data.get("commits", [])
|
|
145
|
+
commits = []
|
|
146
|
+
|
|
147
|
+
for commit_data in commits_raw:
|
|
148
|
+
commit = commit_data["commit"]
|
|
149
|
+
author, committer = commit_data.get("author"), commit_data.get("committer")
|
|
150
|
+
|
|
151
|
+
author_user = None
|
|
152
|
+
if author and commit.get("author"):
|
|
153
|
+
author_user = User(
|
|
154
|
+
id=author.get("id"),
|
|
155
|
+
username=author.get("login"),
|
|
156
|
+
profile_url=author.get("html_url"),
|
|
157
|
+
display_name=commit["author"].get("name"),
|
|
158
|
+
email=commit["author"].get("email"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
committer_user = None
|
|
162
|
+
if committer and commit.get("committer"):
|
|
163
|
+
committer_user = User(
|
|
164
|
+
id=committer.get("id"),
|
|
165
|
+
username=committer.get("login"),
|
|
166
|
+
profile_url=committer.get("html_url"),
|
|
167
|
+
display_name=commit["committer"].get("name"),
|
|
168
|
+
email=commit["committer"].get("email"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
commits.append(
|
|
172
|
+
Commit(
|
|
173
|
+
sha=commit_data["sha"],
|
|
174
|
+
message=commit["message"],
|
|
175
|
+
author=author_user,
|
|
176
|
+
committer=committer_user,
|
|
177
|
+
authored_at=commit.get("author", {}).get("date"),
|
|
178
|
+
committed_at=commit.get("committer", {}).get("date"),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return str(compare_data.get("patch_url")), commits
|
|
183
|
+
|
|
184
|
+
def _get_diff_for_version(self, repository: Repository, repository_version: RepositoryVersion) -> RepositoryVersion:
|
|
185
|
+
"""
|
|
186
|
+
Pull commits associated with the given RepositoryVersion by
|
|
187
|
+
comparing it to its previous ref.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
patch_url, commits = self._load_commits_between_refs(
|
|
191
|
+
repository,
|
|
192
|
+
repository_version.ref_previous,
|
|
193
|
+
repository_version.ref_name,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
repository_version.patch_url = patch_url
|
|
197
|
+
repository_version.commits = commits
|
|
198
|
+
|
|
199
|
+
return repository_version
|
|
200
|
+
|
|
201
|
+
def _load_versions_from_tags(self, repository: Repository, versions_set: set[str]) -> Iterable[RepositoryVersion]:
|
|
202
|
+
"""
|
|
203
|
+
Fetch tags from a GitHub repo and convert them to RepositoryVersion objects.
|
|
204
|
+
"""
|
|
205
|
+
url = f"{GITHUB_API}/repos/{repository.owner}/{repository.name}/tags"
|
|
206
|
+
|
|
207
|
+
n = 0
|
|
208
|
+
for tag in self._paginate_github_api_request(url):
|
|
209
|
+
version = tag["name"]
|
|
210
|
+
if version in versions_set:
|
|
211
|
+
source_url = f"{repository.html_url}/releases/tag/{tag['name']}"
|
|
212
|
+
yield RepositoryVersion(
|
|
213
|
+
version_source_type=VERSION_DATA_SOURCE_GITHUB_TAGS,
|
|
214
|
+
version=version,
|
|
215
|
+
ref_name=tag["name"],
|
|
216
|
+
release_name=None,
|
|
217
|
+
source_url=source_url,
|
|
218
|
+
commits=None,
|
|
219
|
+
ref_previous=None,
|
|
220
|
+
release_notes=None,
|
|
221
|
+
patch_url=None,
|
|
222
|
+
)
|
|
223
|
+
n += 1
|
|
224
|
+
if n == len(versions_set):
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
def repository_info(self, repository_url: str | None) -> Repository:
|
|
228
|
+
"""
|
|
229
|
+
Extract GitHub repository info from a given github URL.
|
|
230
|
+
"""
|
|
231
|
+
if repository_url is None:
|
|
232
|
+
raise ValueError("Repository URL cannot be None")
|
|
233
|
+
s = repository_url.strip().removeprefix("git+").removeprefix("https://")
|
|
234
|
+
m = re.search(r"github\.com[:/](?P<owner>[^/]+)/(?P<name>[^/.]+)", s)
|
|
235
|
+
|
|
236
|
+
if not m:
|
|
237
|
+
raise ValueError(f"Invalid GitHub URL: {repository_url}")
|
|
238
|
+
|
|
239
|
+
owner, repo_name = m.group("owner"), m.group("name")
|
|
240
|
+
|
|
241
|
+
# Fetch repository details to get the description
|
|
242
|
+
repo_api_url = f"{GITHUB_API}/repos/{owner}/{repo_name}"
|
|
243
|
+
_, repo_data = self._make_github_api_request(repo_api_url)
|
|
244
|
+
|
|
245
|
+
return Repository(
|
|
246
|
+
provider=RepositoryProvider.PROVIDER_GITHUB,
|
|
247
|
+
name=repo_name,
|
|
248
|
+
owner=owner,
|
|
249
|
+
description=repo_data.get("description"),
|
|
250
|
+
html_url=f"https://github.com/{owner}/{repo_name}",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def repository_versions(
|
|
254
|
+
self,
|
|
255
|
+
repository: Repository,
|
|
256
|
+
package_versions: list[PackageVersion],
|
|
257
|
+
comparator: Callable,
|
|
258
|
+
) -> Iterable[RepositoryVersion]:
|
|
259
|
+
"""
|
|
260
|
+
Pull versions info available from the given repository. Github releases
|
|
261
|
+
is the default way to get it, then fallback to tags.
|
|
262
|
+
"""
|
|
263
|
+
versions_set = {pv.version for pv in package_versions}
|
|
264
|
+
|
|
265
|
+
# 1. Try loading from releases first, as they contain more metadata.
|
|
266
|
+
releases = list(self._load_releases(repository, versions_set))
|
|
267
|
+
|
|
268
|
+
released_versions = releases
|
|
269
|
+
|
|
270
|
+
# 2. If some versions were not found as releases, fall back to checking tags.
|
|
271
|
+
# This handles cases where a version is tagged but not formally released.
|
|
272
|
+
if len(releases) != len(versions_set):
|
|
273
|
+
released_versions_set = {rv.version for rv in releases}
|
|
274
|
+
missing_versions = versions_set - released_versions_set
|
|
275
|
+
tags_as_versions = self._load_versions_from_tags(repository, missing_versions)
|
|
276
|
+
released_versions = list(itertools.chain(releases, tags_as_versions))
|
|
277
|
+
|
|
278
|
+
# 3. Sort all found versions semantically.
|
|
279
|
+
versions = sort_versions(released_versions, comparator=comparator)
|
|
280
|
+
if not versions:
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# 4. The first (oldest) version is our baseline; yield it as is.
|
|
284
|
+
yield versions[0]
|
|
285
|
+
|
|
286
|
+
# 5. For all subsequent versions, calculate the commit difference from the previous one.
|
|
287
|
+
for version_from, version_to in itertools.zip_longest(versions, versions[1:]):
|
|
288
|
+
if not version_to:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
version_to.ref_previous = version_from.ref_name
|
|
292
|
+
|
|
293
|
+
version_to_with_diff = self._get_diff_for_version(repository, version_to)
|
|
294
|
+
|
|
295
|
+
yield version_to_with_diff
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interfaces related to external APIs
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
|
|
8
|
+
from ossiq.domain.common import ProjectPackagesRegistry
|
|
9
|
+
from ossiq.domain.cve import CVE
|
|
10
|
+
from ossiq.domain.package import Package
|
|
11
|
+
from ossiq.domain.packages_manager import PackageManagerType
|
|
12
|
+
from ossiq.domain.project import Project
|
|
13
|
+
from ossiq.settings import Settings
|
|
14
|
+
|
|
15
|
+
from ..domain.repository import Repository
|
|
16
|
+
from ..domain.version import PackageVersion, RepositoryVersion, VersionsDifference
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AbstractSourceCodeProviderApi(abc.ABC):
|
|
20
|
+
"""
|
|
21
|
+
Abstract client to communicate with source code repositories like GitHub
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
def repository_info(self, repository_url: str | None) -> Repository:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def repository_versions(
|
|
30
|
+
self, repository: Repository, package_versions: list[PackageVersion], comparator: Callable
|
|
31
|
+
) -> Iterable[RepositoryVersion]:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@abc.abstractmethod
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AbstractPackageRegistryApi(abc.ABC):
|
|
40
|
+
"""
|
|
41
|
+
Abstract client to communicate with package registries like PyPi or NPM
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
settings: Settings
|
|
45
|
+
package_registry: ProjectPackagesRegistry
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
@abc.abstractmethod
|
|
49
|
+
def compare_versions(v1: str, v2: str) -> int:
|
|
50
|
+
"""
|
|
51
|
+
Compare two versions regardless of the registry.
|
|
52
|
+
|
|
53
|
+
Versioning is registry-specific, for example
|
|
54
|
+
JavaScript/NPM follows Semantic Versioning strictly,
|
|
55
|
+
while Python/PyPI ecosystem follows PEP 440.
|
|
56
|
+
"""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
@abc.abstractmethod
|
|
61
|
+
def difference_versions(v1_str: str | None, v2_str: str | None) -> VersionsDifference:
|
|
62
|
+
"""
|
|
63
|
+
Calculate version difference using registry-specific semantics.
|
|
64
|
+
|
|
65
|
+
Categorizes the difference between two versions (major, minor, patch, etc.)
|
|
66
|
+
based on the versioning scheme used by the registry.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
v1: First version string (e.g., installed version)
|
|
70
|
+
v2: Second version string (e.g., latest version)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
VersionsDifference object with categorized diff index
|
|
74
|
+
"""
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
@abc.abstractmethod
|
|
78
|
+
def package_info(self, package_name: str) -> Package:
|
|
79
|
+
"""
|
|
80
|
+
Get a particular package info
|
|
81
|
+
"""
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
|
|
84
|
+
@abc.abstractmethod
|
|
85
|
+
def package_versions(self, package_name: str) -> Iterable[PackageVersion]:
|
|
86
|
+
"""
|
|
87
|
+
Get a particular package versions between what is installed
|
|
88
|
+
currently in the project and the latest version available
|
|
89
|
+
"""
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
@abc.abstractmethod
|
|
93
|
+
def __repr__(self):
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AbstractCveDatabaseApi(abc.ABC):
|
|
98
|
+
"""
|
|
99
|
+
Abstract client to communicate with CVEs repositories like osv.dev or github CVE APIs
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
@abc.abstractmethod
|
|
103
|
+
def get_cves_for_package(self, package: Package, version: PackageVersion) -> set[CVE]:
|
|
104
|
+
"""
|
|
105
|
+
Method to return a particular CVE info
|
|
106
|
+
"""
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
@abc.abstractmethod
|
|
110
|
+
def __repr__(self):
|
|
111
|
+
raise NotImplementedError
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AbstractPackageManagerApi(abc.ABC):
|
|
115
|
+
"""
|
|
116
|
+
Abstract Package Manager to extract installed versions
|
|
117
|
+
of packages from different package managers.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
settings: Settings
|
|
121
|
+
package_manager_type: PackageManagerType
|
|
122
|
+
project_path: str
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
@abc.abstractmethod
|
|
126
|
+
def has_package_manager(project_path: str) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Detect that package manager is used in a project_path
|
|
129
|
+
"""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
@abc.abstractmethod
|
|
133
|
+
def project_info(self) -> Project:
|
|
134
|
+
"""
|
|
135
|
+
Extract project dependencies using file format from a specific
|
|
136
|
+
package manager.
|
|
137
|
+
"""
|
|
138
|
+
pass
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of Package Registry API client for NPM
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
import semver
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from ossiq.adapters.api_interfaces import AbstractPackageRegistryApi
|
|
12
|
+
from ossiq.domain.common import ProjectPackagesRegistry
|
|
13
|
+
from ossiq.domain.package import Package
|
|
14
|
+
from ossiq.domain.version import (
|
|
15
|
+
VERSION_DIFF_BUILD,
|
|
16
|
+
VERSION_DIFF_MAJOR,
|
|
17
|
+
VERSION_DIFF_MINOR,
|
|
18
|
+
VERSION_DIFF_PATCH,
|
|
19
|
+
VERSION_DIFF_PRERELEASE,
|
|
20
|
+
VERSION_INVERSED_DIFF_TYPES_MAP,
|
|
21
|
+
VERSION_LATEST,
|
|
22
|
+
VERSION_NO_DIFF,
|
|
23
|
+
PackageVersion,
|
|
24
|
+
VersionsDifference,
|
|
25
|
+
create_version_difference_no_diff,
|
|
26
|
+
)
|
|
27
|
+
from ossiq.settings import Settings
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
NPM_REGISTRY = "https://registry.npmjs.org"
|
|
32
|
+
NPM_REGISTRY_FRONT = "https://www.npmjs.com"
|
|
33
|
+
|
|
34
|
+
NPM_DEPENDENCIES_SECTIONS = (
|
|
35
|
+
"dependencies",
|
|
36
|
+
"devDependencies",
|
|
37
|
+
"peerDependencies",
|
|
38
|
+
"optionalDependencies",
|
|
39
|
+
# FIXME: consider pinned versions as well!
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PackageRegistryApiNpm(AbstractPackageRegistryApi):
|
|
44
|
+
"""
|
|
45
|
+
Implementation of Package Registry API client for NPM
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
package_registry = ProjectPackagesRegistry.NPM
|
|
49
|
+
settings: Settings
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def compare_versions(v1: str, v2: str) -> int:
|
|
53
|
+
"""
|
|
54
|
+
Compare two versions following Semantic Versioning.
|
|
55
|
+
"""
|
|
56
|
+
return semver.Version.parse(v1).compare(semver.Version.parse(v2))
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _calculate_semver_diff_index(v1: semver.Version, v2: semver.Version) -> int:
|
|
60
|
+
"""
|
|
61
|
+
Calculate the most significant difference between two semver versions.
|
|
62
|
+
|
|
63
|
+
Compares version components in order of significance:
|
|
64
|
+
1. Major version
|
|
65
|
+
2. Minor version
|
|
66
|
+
3. Patch version
|
|
67
|
+
4. Prerelease
|
|
68
|
+
5. Build metadata
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
v1: First parsed semver version
|
|
72
|
+
v2: Second parsed semver version
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Diff index constant indicating the most significant difference level
|
|
76
|
+
"""
|
|
77
|
+
if v1.major != v2.major:
|
|
78
|
+
return VERSION_DIFF_MAJOR
|
|
79
|
+
|
|
80
|
+
if v1.minor != v2.minor:
|
|
81
|
+
return VERSION_DIFF_MINOR
|
|
82
|
+
|
|
83
|
+
if v1.patch != v2.patch:
|
|
84
|
+
return VERSION_DIFF_PATCH
|
|
85
|
+
|
|
86
|
+
if v1.prerelease != v2.prerelease:
|
|
87
|
+
return VERSION_DIFF_PRERELEASE
|
|
88
|
+
|
|
89
|
+
if v1.build != v2.build:
|
|
90
|
+
return VERSION_DIFF_BUILD
|
|
91
|
+
|
|
92
|
+
return VERSION_NO_DIFF
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def difference_versions(v1_str: str | None, v2_str: str | None) -> VersionsDifference:
|
|
96
|
+
"""
|
|
97
|
+
Calculate version difference using Semantic Versioning (semver) semantics.
|
|
98
|
+
|
|
99
|
+
NPM packages follow strict semver, so we parse and compare major, minor,
|
|
100
|
+
patch, prerelease, and build components.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
v1_str: First version string (e.g., installed version)
|
|
104
|
+
v2_str: Second version string (e.g., latest version)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
VersionsDifference with categorized diff index
|
|
108
|
+
"""
|
|
109
|
+
# Handle None/empty versions
|
|
110
|
+
if not v1_str or not v2_str:
|
|
111
|
+
return create_version_difference_no_diff(v1_str, v2_str)
|
|
112
|
+
|
|
113
|
+
# Optimize: check string equality before parsing
|
|
114
|
+
if v1_str == v2_str:
|
|
115
|
+
return VersionsDifference(
|
|
116
|
+
v1_str, v2_str, VERSION_LATEST, diff_name=VERSION_INVERSED_DIFF_TYPES_MAP[VERSION_LATEST]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Parse versions
|
|
120
|
+
v1 = semver.Version.parse(v1_str)
|
|
121
|
+
v2 = semver.Version.parse(v2_str)
|
|
122
|
+
|
|
123
|
+
# Calculate the difference
|
|
124
|
+
diff_index = PackageRegistryApiNpm._calculate_semver_diff_index(v1, v2)
|
|
125
|
+
|
|
126
|
+
return VersionsDifference(str(v1), str(v2), diff_index, diff_name=VERSION_INVERSED_DIFF_TYPES_MAP[diff_index])
|
|
127
|
+
|
|
128
|
+
def __init__(self, settings: Settings):
|
|
129
|
+
self.settings = settings
|
|
130
|
+
|
|
131
|
+
def __repr__(self):
|
|
132
|
+
return "<PackageRegistryApiNpm instance>"
|
|
133
|
+
|
|
134
|
+
def _make_request(self, path: str, headers: dict | None = None, timeout: int = 15) -> dict:
|
|
135
|
+
"""
|
|
136
|
+
Make request and handle retries and errors handling.
|
|
137
|
+
"""
|
|
138
|
+
r = requests.get(f"{NPM_REGISTRY}{path}", timeout=timeout, headers=headers)
|
|
139
|
+
r.raise_for_status()
|
|
140
|
+
return r.json()
|
|
141
|
+
|
|
142
|
+
def package_info(self, package_name: str) -> Package:
|
|
143
|
+
"""
|
|
144
|
+
Fetch npm info for a given package.
|
|
145
|
+
FIXME: raise custom exception if not found
|
|
146
|
+
"""
|
|
147
|
+
response = self._make_request(f"/{package_name}")
|
|
148
|
+
distribution_tags = response.get("dist-tags", {"latest": None, "next": None})
|
|
149
|
+
|
|
150
|
+
return Package(
|
|
151
|
+
registry=ProjectPackagesRegistry.NPM,
|
|
152
|
+
name=response["name"],
|
|
153
|
+
latest_version=distribution_tags.get("latest", None),
|
|
154
|
+
next_version=distribution_tags.get("next", None),
|
|
155
|
+
repo_url=response.get("repository", {}).get("url", None),
|
|
156
|
+
author=response.get("author"),
|
|
157
|
+
homepage_url=response.get("homepage"),
|
|
158
|
+
description=response.get("description"),
|
|
159
|
+
package_url=f"{NPM_REGISTRY_FRONT}/package/{package_name}/",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def package_versions(self, package_name: str) -> Iterable[PackageVersion]:
|
|
163
|
+
"""
|
|
164
|
+
Fetch npm versions for a given package.
|
|
165
|
+
"""
|
|
166
|
+
response = self._make_request(f"/{package_name}")
|
|
167
|
+
# FIXME: raise custom exception if not found
|
|
168
|
+
versions = response.get("versions", [])
|
|
169
|
+
timestamp_map = response.get("time", {})
|
|
170
|
+
unpublished_response = timestamp_map.pop("unpublished", {})
|
|
171
|
+
|
|
172
|
+
# Package version is either published or unpublished
|
|
173
|
+
if unpublished_response:
|
|
174
|
+
unpublished_date_iso = unpublished_response.get("time", None)
|
|
175
|
+
for version in unpublished_response.get("versions", []):
|
|
176
|
+
yield PackageVersion(
|
|
177
|
+
version=version,
|
|
178
|
+
license=None,
|
|
179
|
+
dependencies={},
|
|
180
|
+
package_url=f"{NPM_REGISTRY_FRONT}/package/{package_name}/v/{version}",
|
|
181
|
+
unpublished_date_iso=unpublished_date_iso,
|
|
182
|
+
is_published=False,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
for version, details in versions.items():
|
|
186
|
+
yield PackageVersion(
|
|
187
|
+
version=version,
|
|
188
|
+
published_date_iso=timestamp_map.get(version, None),
|
|
189
|
+
dependencies=details.get("dependencies", {}),
|
|
190
|
+
license=details.get("license", None),
|
|
191
|
+
runtime_requirements=details.get("engines", None),
|
|
192
|
+
dev_dependencies=details.get("devDependencies", {}),
|
|
193
|
+
description=details.get("description", None),
|
|
194
|
+
package_url=f"{NPM_REGISTRY_FRONT}/package/{package_name}/v/{version}",
|
|
195
|
+
)
|