depsdev 0.0.3__tar.gz → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: depsdev
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Python wrapper for https://deps.dev/ API
5
5
  Project-URL: Documentation, https://github.com/FlavioAmurrioCS/depsdev#readme
6
6
  Project-URL: Issues, https://github.com/FlavioAmurrioCS/depsdev/issues
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
20
20
  Classifier: Programming Language :: Python :: Implementation :: PyPy
21
21
  Requires-Python: >=3.9
22
22
  Requires-Dist: httpx
23
+ Requires-Dist: packageurl-python
23
24
  Provides-Extra: cli
24
25
  Requires-Dist: rich; extra == 'cli'
25
26
  Requires-Dist: typer-slim; extra == 'cli'
@@ -27,6 +27,7 @@ classifiers = [
27
27
  ]
28
28
  dependencies = [
29
29
  "httpx",
30
+ "packageurl-python",
30
31
  ]
31
32
 
32
33
  [project.optional-dependencies]
@@ -1,6 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import sys
5
+
6
+ from depsdev.cli.purl import get_extractor
7
+ from depsdev.cli.vuln import main_helper
8
+
9
+ try:
10
+ import typer
11
+ except ImportError:
12
+ msg = (
13
+ "The 'cli' optional dependency is not installed. "
14
+ "Please install it with 'pip install depsdev[cli]'."
15
+ )
16
+ print(msg, file=sys.stderr)
17
+ raise SystemExit(1) from None
18
+
4
19
  import os
5
20
  from textwrap import dedent
6
21
  from typing import TYPE_CHECKING
@@ -14,7 +29,6 @@ if TYPE_CHECKING:
14
29
  P = ParamSpec("P")
15
30
  R = TypeVar("R")
16
31
 
17
-
18
32
  logging.basicConfig(
19
33
  level=logging.ERROR,
20
34
  format="[%(asctime)s] [%(levelname)-7s] [%(name)s] %(message)s",
@@ -38,7 +52,9 @@ def to_sync() -> Callable[[Callable[P, R]], Callable[P, R]]:
38
52
 
39
53
  from rich import print_json
40
54
 
41
- print_json(data=asyncio.run(func(*args, **kwargs))) # type: ignore[arg-type]
55
+ result: object = asyncio.run(func(*args, **kwargs)) # type: ignore[arg-type]
56
+ if result is not None:
57
+ print_json(data=result)
42
58
  except Exception:
43
59
  logger.exception("An error occurred while executing the command.")
44
60
  raise SystemExit(1) from None
@@ -50,25 +66,14 @@ def to_sync() -> Callable[[Callable[P, R]], Callable[P, R]]:
50
66
  return decorator
51
67
 
52
68
 
53
- def main() -> None:
69
+ def create_app() -> typer.Typer:
54
70
  """
55
71
  Main entry point for the CLI.
56
72
  """
57
-
58
- try:
59
- import typer
60
- except ImportError:
61
- msg = (
62
- "The 'cli' optional dependency is not installed. "
63
- "Please install it with 'pip install depsdev[cli]'."
64
- )
65
- logger.error(msg) # noqa: TRY400
66
- raise SystemExit(1) from None
67
-
68
73
  alpha = os.environ.get("DEPSDEV_V3_ALPHA", "false").lower() in ("true", "1", "yes")
69
74
 
70
75
  app = typer.Typer(
71
- name="depsdev",
76
+ name="api",
72
77
  no_args_is_help=True,
73
78
  rich_markup_mode="rich",
74
79
  help=dedent(
@@ -125,7 +130,49 @@ def main() -> None:
125
130
  app.command(rich_help_panel="v3alpha")(to_sync()(client_v3_alpha.purl_lookup_batch))
126
131
  app.command(rich_help_panel="v3alpha")(to_sync()(client_v3_alpha.query_container_images))
127
132
 
128
- return app()
133
+ return app
134
+
135
+
136
+ main = typer.Typer(
137
+ name="depsdev",
138
+ no_args_is_help=True,
139
+ rich_markup_mode="rich",
140
+ )
141
+
142
+ main.add_typer(
143
+ create_app(),
144
+ name="api",
145
+ )
146
+
147
+
148
+ @main.command(name="purl", rich_help_panel="Utils")
149
+ def purl(filename: str) -> None:
150
+ """
151
+ Extract package URLs from various formats.
152
+ """
153
+ extractor = get_extractor(filename)
154
+
155
+ for purl in extractor.extract(filename):
156
+ print(purl)
157
+
158
+
159
+ main.command(name="vuln", rich_help_panel="Utils")(to_sync()(main_helper))
160
+
161
+
162
+ @main.command()
163
+ @to_sync()
164
+ async def report(filename: str) -> None:
165
+ """
166
+ Show vulnerabilities for packages in a file.
167
+
168
+ Example usage:
169
+ depsdev report requirements.txt
170
+ depsdev report pom.xml
171
+ depsdev report Pipfile.lock
172
+ """
173
+ extractor = get_extractor(filename)
174
+ packages = extractor.extract(filename)
175
+ await main_helper([x.to_string() for x in packages])
129
176
 
130
177
 
131
178
  if __name__ == "__main__":
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.0.3'
21
- __version_tuple__ = version_tuple = (0, 0, 3)
20
+ __version__ = version = '0.0.4'
21
+ __version_tuple__ = version_tuple = (0, 0, 4)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from dataclasses import field
6
+ from typing import TYPE_CHECKING
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+
11
+ if TYPE_CHECKING:
12
+ from httpx._types import QueryParamTypes
13
+ from typing_extensions import Literal
14
+
15
+ from depsdev.v3 import Incomplete
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class BaseClient:
22
+ base_url: str
23
+ timeout: float = 5.0
24
+ client: httpx.AsyncClient = field(init=False, repr=False)
25
+
26
+ def __post_init__(self) -> None:
27
+ self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
28
+
29
+ async def _requests(
30
+ self,
31
+ url: str = "",
32
+ method: Literal["GET", "POST"] = "GET",
33
+ params: QueryParamTypes | None = None,
34
+ json: object | None = None,
35
+ ) -> Incomplete:
36
+ logger.info(locals())
37
+ response = await self.client.request(method=method, url=url, params=params, json=json)
38
+ if not response.is_success:
39
+ logger.error(
40
+ "Request failed with status code %s: %s", response.status_code, response.text
41
+ )
42
+ response.raise_for_status()
43
+ return response.json()
44
+
45
+ @staticmethod
46
+ def url_escape(string: str) -> str:
47
+ return quote(string, safe="")
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import json
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ from packageurl import PackageURL
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MavenExtractor:
20
+ @classmethod
21
+ def extract(cls, filename: str) -> Iterable[PackageURL]:
22
+ yield from (cls.parse_single_line(x) for x in cls._clean(cls._get_source(filename)))
23
+
24
+ @staticmethod
25
+ def _get_source(filename: str) -> Iterable[str]:
26
+ """
27
+ Read lines from stdin or a file.
28
+ """
29
+ if not filename.endswith("pom.xml"):
30
+ logger.error("Invalid POM file: %s. It should end with 'pom.xml'.", filename)
31
+ raise SystemExit(1)
32
+ result = subprocess.run(
33
+ ["mvn", "dependency:tree"], # noqa: S607
34
+ check=False,
35
+ capture_output=True,
36
+ text=True,
37
+ cwd=os.path.dirname(os.path.abspath(filename)),
38
+ )
39
+ if result.returncode != 0:
40
+ print(result.stderr, file=sys.stderr)
41
+ raise SystemExit(1)
42
+ yield from result.stdout.splitlines()
43
+
44
+ @staticmethod
45
+ def parse_single_line(line: str) -> PackageURL:
46
+ """
47
+ Parse a single line of Maven dependency output and return a PackageURL object.
48
+ """
49
+ package, *rest = line.split()
50
+ _is_optional = bool(rest)
51
+ group, artifact, _type, version, *classifier = package.split(":")
52
+ return PackageURL(
53
+ type="maven",
54
+ namespace=group,
55
+ name=artifact,
56
+ version=version,
57
+ qualifiers=None,
58
+ subpath=None,
59
+ )
60
+
61
+ @staticmethod
62
+ def _clean(lines: Iterable[str]) -> Iterable[str]:
63
+ stage1 = (x.rstrip() for x in lines if x.strip())
64
+ stage2 = itertools.dropwhile(lambda x: not x.startswith("[INFO] --- "), stage1)
65
+ stage3 = itertools.takewhile(
66
+ lambda x: not x.startswith(
67
+ "[INFO] ------------------------------------------------------------------------"
68
+ ),
69
+ stage2,
70
+ )
71
+ stage4 = itertools.islice(stage3, 1, None) # Skip the first line
72
+ stage5 = (x[7:] for x in stage4)
73
+ yield from (x.split("- ", maxsplit=1)[-1] for x in stage5)
74
+
75
+
76
+ class PipfileLockExtractor:
77
+ @classmethod
78
+ def extract(cls, filename: str) -> Iterable[PackageURL]:
79
+ """
80
+ Extracts package URLs from a Pipfile.lock.
81
+ """
82
+ if not filename.endswith("Pipfile.lock"):
83
+ logger.error("Invalid Pipfile.lock: %s. It should end with 'Pipfile.lock'.", filename)
84
+ raise SystemExit(1)
85
+ with open(filename) as f:
86
+ data = json.load(f)
87
+ for package_name, package_info in data.get("default", {}).items():
88
+ version: str | None = package_info.get("version")
89
+ if version:
90
+ yield PackageURL(
91
+ type="pypi",
92
+ namespace=None,
93
+ name=package_name,
94
+ version=version[2:],
95
+ qualifiers=None,
96
+ subpath=None,
97
+ )
98
+ else:
99
+ logger.warning("Package %s has no version specified.", package_name)
100
+
101
+
102
+ class RequirementsExtractor:
103
+ @classmethod
104
+ def extract(cls, filename: str) -> Iterable[PackageURL]:
105
+ """
106
+ Extracts package URLs from a requirements.txt file.
107
+ """
108
+ if not filename.endswith("requirements.txt"):
109
+ logger.error(
110
+ "Invalid requirements file: %s. It should end with 'requirements.txt'.", filename
111
+ )
112
+ raise SystemExit(1)
113
+ with open(filename) as f:
114
+ for line in f:
115
+ _line = line.strip()
116
+ if not _line or _line.startswith(("#", "-r ", "-i ")):
117
+ continue
118
+ parts = _line.split(";")[0].split("==")
119
+ if len(parts) == 2: # noqa: PLR2004
120
+ name, version = parts
121
+ yield PackageURL(
122
+ type="pypi",
123
+ namespace=None,
124
+ name=name,
125
+ version=version,
126
+ qualifiers=None,
127
+ subpath=None,
128
+ )
129
+
130
+
131
+ def get_extractor(filename: str) -> MavenExtractor | PipfileLockExtractor | RequirementsExtractor:
132
+ """
133
+ Returns the appropriate extractor based on the file extension.
134
+ """
135
+ if filename.endswith("pom.xml"):
136
+ return MavenExtractor()
137
+ if filename.endswith("Pipfile.lock"):
138
+ return PipfileLockExtractor()
139
+ if filename.endswith("requirements.txt"):
140
+ return RequirementsExtractor()
141
+ logger.error("Unsupported file format: %s", filename)
142
+ raise SystemExit(1)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import itertools
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from depsdev.osv import OSVClientV1
12
+
13
+ if TYPE_CHECKING:
14
+ from depsdev.osv import OSVVulnerability
15
+ from depsdev.osv import V1Query
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_version_fix(vuln: OSVVulnerability) -> str | None:
21
+ for affected in vuln.get("affected", []):
22
+ for _range in affected.get("ranges", []):
23
+ for event in _range.get("events", []):
24
+ if "fixed" in event:
25
+ return event["fixed"]
26
+ return None
27
+
28
+
29
+ async def get_vulns(purls: list[str], osv_client: OSVClientV1) -> dict[str, list[OSVVulnerability]]:
30
+ queries: list[V1Query] = [
31
+ {
32
+ "package": {"purl": purl},
33
+ }
34
+ for purl in purls
35
+ ]
36
+ result = await osv_client.querybatch({"queries": queries})
37
+ r = {k: [x["id"] for x in v["vulns"]] for k, v in zip(purls, result["results"]) if v}
38
+ all_result = await asyncio.gather(
39
+ *[osv_client.get_vuln(vuln_id) for vuln_id in itertools.chain.from_iterable(r.values())]
40
+ )
41
+ look_up = {vuln["id"]: vuln for vuln in all_result}
42
+ return {purl: [look_up[vuln_id] for vuln_id in vuln_ids] for purl, vuln_ids in r.items()}
43
+
44
+
45
+ async def main_helper(packages: list[str]) -> int:
46
+ """Main function to analyze packages for vulnerabilities."""
47
+
48
+ console = Console()
49
+
50
+ console.print(f"Analysing {len(packages)} packages...")
51
+
52
+ osv_client = OSVClientV1()
53
+
54
+ results = await get_vulns(packages, osv_client)
55
+ console.print(f"Found {len(results)} packages with advisories.")
56
+
57
+ for purl, advisories in results.items():
58
+ table = Table(title=purl)
59
+
60
+ table.add_column("Id")
61
+ table.add_column("Summary", style="cyan", no_wrap=True)
62
+ table.add_column("Fixed", style="magenta")
63
+
64
+ for vuln in advisories:
65
+ table.add_row(
66
+ f"[link=https://github.com/advisories/{vuln['id']}]{vuln['id']}[/link]",
67
+ vuln["summary"],
68
+ get_version_fix(vuln) or "unknown",
69
+ )
70
+ console.print(table)
71
+ return 0
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from depsdev.base import BaseClient
8
+
9
+ if TYPE_CHECKING:
10
+ from typing import Any
11
+ from typing import Literal
12
+
13
+ from typing_extensions import NotRequired
14
+ from typing_extensions import TypedDict
15
+
16
+ class OSVEvent(TypedDict):
17
+ introduced: NotRequired[str]
18
+ fixed: NotRequired[str]
19
+ limit: NotRequired[str]
20
+ lastAffected: NotRequired[str]
21
+
22
+ class OSVRange(TypedDict):
23
+ type: NotRequired[
24
+ Literal[
25
+ "UNSPECIFIED",
26
+ "GIT",
27
+ "SEMVER",
28
+ "ECOSYSTEM",
29
+ ]
30
+ ]
31
+ repo: NotRequired[str]
32
+ events: NotRequired[list[OSVEvent]]
33
+
34
+ class OSVPackage(TypedDict):
35
+ name: NotRequired[str]
36
+ ecosystem: NotRequired[str]
37
+ purl: NotRequired[str]
38
+
39
+ class OSVCredit(TypedDict):
40
+ name: NotRequired[str]
41
+ contact: NotRequired[list[str]]
42
+ type: NotRequired[
43
+ Literal[
44
+ "UNSPECIFIED",
45
+ "OTHER",
46
+ "FINDER",
47
+ "REPORTER",
48
+ "ANALYST",
49
+ "COORDINATOR",
50
+ "REMEDIATION_DEVELOPER",
51
+ "REMEDIATION_REVIEWER",
52
+ "REMEDIATION_VERIFIER",
53
+ "TOOL",
54
+ "SPONSOR",
55
+ ]
56
+ ]
57
+
58
+ class OSVSeverity(TypedDict):
59
+ type: NotRequired[
60
+ Literal[
61
+ "UNSPECIFIED",
62
+ "CVSS_V4",
63
+ "CVSS_V3",
64
+ "CVSS_V2",
65
+ ]
66
+ ]
67
+ score: NotRequired[str]
68
+
69
+ class OSVReference(TypedDict):
70
+ type: NotRequired[
71
+ Literal[
72
+ "NONE",
73
+ "WEB",
74
+ "ADVISORY",
75
+ "REPORT",
76
+ "FIX",
77
+ "PACKAGE",
78
+ "ARTICLE",
79
+ "EVIDENCE",
80
+ ]
81
+ ]
82
+ url: NotRequired[str]
83
+
84
+ class OSVAffected(TypedDict):
85
+ package: NotRequired[OSVPackage]
86
+ ranges: NotRequired[list[OSVRange]]
87
+ versions: NotRequired[list[str]]
88
+ ecosystemSpecific: NotRequired[dict[str, Any]]
89
+ databaseSpecific: NotRequired[dict[str, Any]]
90
+ severity: NotRequired[list[OSVSeverity]]
91
+
92
+ class OSVVulnerability(TypedDict):
93
+ id: str
94
+ summary: str
95
+ schemaVersion: NotRequired[str]
96
+ published: NotRequired[str]
97
+ modified: NotRequired[str]
98
+ withdrawn: NotRequired[str]
99
+ aliases: NotRequired[list[str]]
100
+ related: NotRequired[list[str]]
101
+ details: NotRequired[str]
102
+ affected: NotRequired[list[OSVAffected]]
103
+ references: NotRequired[list[OSVReference]]
104
+ databaseSpecific: NotRequired[dict[str, Any]]
105
+ severity: NotRequired[list[OSVSeverity]]
106
+ credits: NotRequired[list[OSVCredit]]
107
+
108
+ class V1VulnerabilityList(TypedDict):
109
+ vulns: NotRequired[list[OSVVulnerability]]
110
+ nextPageToken: NotRequired[str]
111
+
112
+ class V1Query(TypedDict):
113
+ commit: NotRequired[str]
114
+ version: NotRequired[str]
115
+ package: NotRequired[OSVPackage]
116
+ page_token: NotRequired[str]
117
+
118
+ #########################################
119
+
120
+ class V1Batchquery(TypedDict):
121
+ queries: list[V1Query]
122
+
123
+ class QueryBatchResult(TypedDict):
124
+ vulns: list[dict[Literal["id", "modified"], str]]
125
+ next_page_token: NotRequired[str]
126
+
127
+ class QueryBatchResponse(TypedDict):
128
+ results: list[QueryBatchResult]
129
+
130
+
131
+ logger = logging.getLogger(__name__)
132
+
133
+
134
+ @dataclass
135
+ class OSVClientV1(BaseClient):
136
+ base_url: str = "https://api.osv.dev"
137
+
138
+ async def query(self, query: V1Query) -> V1VulnerabilityList:
139
+ """
140
+ Lists vulnerabilities for given package and version. May also be queried by commit hash.
141
+
142
+ POST /v1/query
143
+ """
144
+ return await self._requests(method="POST", url="/v1/query", json=query) # type:ignore[return-value]
145
+
146
+ async def querybatch(self, query: V1Batchquery) -> QueryBatchResponse:
147
+ """
148
+ Query for multiple packages (by either package and version or git commit hash) at once. Returns vulnerability ids and modified field only. The response ordering will be guaranteed to match the input.
149
+
150
+ POST /v1/querybatc
151
+ """ # noqa: E501
152
+ return await self._requests(method="POST", url="/v1/querybatch", json=query) # type:ignore[return-value]
153
+
154
+ async def get_vuln(self, vuln_id: str) -> OSVVulnerability:
155
+ """
156
+ Returns vulnerability information for a given vulnerability id.
157
+
158
+ GET /v1/vulns/{id}
159
+ """
160
+ return await self._requests(method="GET", url=f"/v1/vulns/{self.url_escape(vuln_id)}") # type:ignore[return-value]
161
+
162
+ # async def import_findings(self) -> Incomplete:
163
+ # """
164
+ # Something like this:
165
+ # """
166
+ # return await self._requests(method="GET", url="/v1experimental/importfindings")
167
+
168
+ # async def determine_version(self) -> Incomplete:
169
+ # """
170
+ # Something like this:
171
+ # """
172
+ # return await self._requests(method="POST", url="/v1experimental/determineversion")
173
+
174
+
175
+ if __name__ == "__main__":
176
+ logging.basicConfig(level=logging.INFO)
177
+ client = OSVClientV1()
178
+ import asyncio
179
+ import json
180
+
181
+ query: V1Query = {
182
+ "package": {"name": "jinja2", "ecosystem": "PyPI"},
183
+ "version": "2.4.1",
184
+ }
185
+ loop = asyncio.get_event_loop()
186
+ a = client.query(query)
187
+ # a = client.get_vuln("GHSA-3mc7-4q67-w48m")
188
+ # # {"name": "org.yaml:snakeyaml", "version": "1.19", "system": "MAVEN"}
189
+ # a = client.query(
190
+ # {"package": {"name": "org.yaml:snakeyaml", "ecosystem": "MAVEN"}, "version": "1.19"}
191
+ # )
192
+ a = client.query({"package": {"purl": "pkg:maven/org.yaml/snakeyaml@1.19"}})
193
+
194
+ result = loop.run_until_complete(a)
195
+ print(json.dumps(result)) # For demonstration purposes, print the result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes