java-dependency-analyzer 1.1.0__tar.gz → 1.1.1__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.
Files changed (32) hide show
  1. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/PKG-INFO +3 -3
  2. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/README.md +2 -2
  3. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/cli.py +11 -1
  4. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/parsers/gradle_dep_tree_parser.py +160 -123
  5. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/scanners/ghsa_scanner.py +224 -205
  6. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/pyproject.toml +1 -1
  7. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/LICENSE +0 -0
  8. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/__init__.py +0 -0
  9. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/cache/__init__.py +0 -0
  10. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/cache/db.py +0 -0
  11. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/cache/vulnerability_cache.py +0 -0
  12. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/models/__init__.py +0 -0
  13. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/models/dependency.py +0 -0
  14. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/models/report.py +0 -0
  15. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/parsers/__init__.py +0 -0
  16. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/parsers/base.py +0 -0
  17. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/parsers/gradle_parser.py +0 -0
  18. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/parsers/maven_dep_tree_parser.py +0 -0
  19. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/parsers/maven_parser.py +0 -0
  20. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/reporters/__init__.py +0 -0
  21. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/reporters/base.py +0 -0
  22. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/reporters/html_reporter.py +0 -0
  23. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/reporters/json_reporter.py +0 -0
  24. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/reporters/templates/report.html +0 -0
  25. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/resolvers/__init__.py +0 -0
  26. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/resolvers/transitive.py +0 -0
  27. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/scanners/__init__.py +0 -0
  28. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/scanners/base.py +0 -0
  29. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/scanners/osv_scanner.py +0 -0
  30. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/util/__init__.py +0 -0
  31. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/java_dependency_analyzer/util/logger.py +0 -0
  32. {java_dependency_analyzer-1.1.0 → java_dependency_analyzer-1.1.1}/logging.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-dependency-analyzer
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Java Dependency Analyzer is a tool that inspects dependencies.
5
5
  License: MIT License
6
6
 
@@ -38,7 +38,7 @@ Requires-Dist: lxml (>=6.0.2,<7.0.0)
38
38
  Requires-Dist: python-dotenv (>=1.2.2,<2.0.0)
39
39
  Description-Content-Type: text/markdown
40
40
 
41
- # Java Dependency Analyzer 1.1.0
41
+ # Java Dependency Analyzer 1.1.1
42
42
 
43
43
  > A Python CLI tool that inspects Java dependency hierarchies in Maven and Gradle projects and reports known vulnerabilities.
44
44
 
@@ -198,7 +198,7 @@ graph TD
198
198
  | `GradleDepTreeParser` | `parsers/gradle_dep_tree_parser.py` | Parses `gradle dependencies` text output into a full dependency tree. |
199
199
  | `TransitiveResolver` | `resolvers/transitive.py` | Fetches transitive dependencies by downloading POM files from Maven Central. |
200
200
  | `OsvScanner` | `scanners/osv_scanner.py` | Queries the [OSV.dev](https://osv.dev/) batch API for known CVEs. |
201
- | `GhsaScanner` | `scanners/ghsa_scanner.py` | Queries the [GitHub Advisory Database](https://github.com/advisories) REST API for security advisories. |
201
+ | `GhsaScanner` | `scanners/ghsa_scanner.py` | Queries the [GitHub Advisory Database](https://github.com/advisories) REST API for security advisories; automatically falls back to OSV when rate-limited (HTTP 403/429). |
202
202
  | `VulnerabilityCache` | `cache/vulnerability_cache.py` | SQLite-backed cache for raw vulnerability API payloads with configurable TTL. |
203
203
  | `DatabaseManager` | `cache/db.py` | Manages SQLite connection lifecycle and schema initialisation. |
204
204
  | `JsonReporter` | `reporters/json_reporter.py` | Writes a `ScanResult` to a JSON file. |
@@ -1,4 +1,4 @@
1
- # Java Dependency Analyzer 1.1.0
1
+ # Java Dependency Analyzer 1.1.1
2
2
 
3
3
  > A Python CLI tool that inspects Java dependency hierarchies in Maven and Gradle projects and reports known vulnerabilities.
4
4
 
@@ -158,7 +158,7 @@ graph TD
158
158
  | `GradleDepTreeParser` | `parsers/gradle_dep_tree_parser.py` | Parses `gradle dependencies` text output into a full dependency tree. |
159
159
  | `TransitiveResolver` | `resolvers/transitive.py` | Fetches transitive dependencies by downloading POM files from Maven Central. |
160
160
  | `OsvScanner` | `scanners/osv_scanner.py` | Queries the [OSV.dev](https://osv.dev/) batch API for known CVEs. |
161
- | `GhsaScanner` | `scanners/ghsa_scanner.py` | Queries the [GitHub Advisory Database](https://github.com/advisories) REST API for security advisories. |
161
+ | `GhsaScanner` | `scanners/ghsa_scanner.py` | Queries the [GitHub Advisory Database](https://github.com/advisories) REST API for security advisories; automatically falls back to OSV when rate-limited (HTTP 403/429). |
162
162
  | `VulnerabilityCache` | `cache/vulnerability_cache.py` | SQLite-backed cache for raw vulnerability API payloads with configurable TTL. |
163
163
  | `DatabaseManager` | `cache/db.py` | Manages SQLite connection lifecycle and schema initialisation. |
164
164
  | `JsonReporter` | `reporters/json_reporter.py` | Writes a `ScanResult` to a JSON file. |
@@ -381,7 +381,17 @@ def _scan_all(
381
381
  for dep in dependencies:
382
382
  if verbose:
383
383
  click.echo(f" Scanning {dep.coordinates}...")
384
- ghsa_vulns = ghsa.scan(dep)
384
+ if not ghsa.rate_limited:
385
+ ghsa_vulns = ghsa.scan(dep)
386
+ if ghsa.rate_limited:
387
+ click.echo(
388
+ " GHSA rate limit exceeded; "
389
+ "falling back to OSV for remaining dependencies.",
390
+ err=True,
391
+ )
392
+ ghsa_vulns = []
393
+ else:
394
+ ghsa_vulns = []
385
395
  dep.vulnerabilities = ghsa_vulns if ghsa_vulns else osv.scan(dep)
386
396
  _scan_all(dep.transitive_dependencies, osv, ghsa, verbose)
387
397
 
@@ -1,123 +1,160 @@
1
- """
2
- gradle_dep_tree_parser module.
3
-
4
- Parses the text output of ``gradle dependencies`` to reconstruct
5
- the full dependency tree including transitive dependencies.
6
-
7
- :author: Ron Webb
8
- :since: 1.0.0
9
- """
10
-
11
- import re
12
-
13
- from ..models.dependency import Dependency
14
- from ..util.logger import setup_logger
15
- from .base import DepTreeParser
16
-
17
- __author__ = "Ron Webb"
18
- __since__ = "1.0.0"
19
-
20
- _logger = setup_logger(__name__)
21
-
22
- # Matches the tree connector characters at the start of a dependency line.
23
- # Each level of indentation is exactly 5 characters wide (" " or "| ").
24
- _INDENT_UNIT = 5
25
- _CONNECTOR_RE = re.compile(r"^((?:[|\\+]\s{3,4}|\s{5})*)([+\\])--- (.+)$")
26
-
27
- # Matches a resolved version arrow, e.g. "1.0 -> 2.0" or "RELEASE -> 4.16.0"
28
- _VERSION_ARROW_RE = re.compile(r"\s*->\s*(\S+)$")
29
-
30
- # Suffix appended to repeated subtree roots by Gradle
31
- _REPEATED_SUFFIX = " (*)"
32
-
33
- # Lines annotated as constraints (not actual dependencies)
34
- _CONSTRAINT_SUFFIX = " (c)"
35
-
36
- # Gradle coordinates: group:artifact:version
37
- _COORD_RE = re.compile(r"^([^:]+):([^:]+):(.+)$")
38
-
39
-
40
- class GradleDepTreeParser(DepTreeParser):
41
- """
42
- Parses the plain-text output of ``gradle dependencies`` and reconstructs
43
- the dependency tree as a list of :class:`~java_dependency_analyzer.models.dependency.Dependency`
44
- objects with nested ``transitive_dependencies``.
45
-
46
- The parser is format-agnostic regarding the configuration name; it processes
47
- the first dependency-tree block it encounters. Users should redirect the
48
- output of the configuration they care about (e.g. ``runtimeClasspath``) into
49
- a text file and pass that file here.
50
-
51
- :author: Ron Webb
52
- :since: 1.0.0
53
- """
54
-
55
- # ------------------------------------------------------------------
56
- # Private helpers
57
- # ------------------------------------------------------------------
58
-
59
- def _line_to_entry(self, line: str) -> tuple[int, bool, Dependency] | None:
60
- """
61
- Convert a single Gradle dep-tree line to a ``(depth, is_leaf, dep)``
62
- entry, or return *None* to skip the line.
63
-
64
- :author: Ron Webb
65
- :since: 1.0.0
66
- """
67
- if line.rstrip().endswith(_CONSTRAINT_SUFFIX):
68
- return None
69
-
70
- match = _CONNECTOR_RE.match(line)
71
- if match is None:
72
- return None
73
-
74
- indent_str, coord_str = match.group(1), match.group(3)
75
- depth = len(indent_str) // _INDENT_UNIT
76
-
77
- is_leaf = coord_str.endswith(_REPEATED_SUFFIX)
78
- if is_leaf:
79
- coord_str = coord_str[: -len(_REPEATED_SUFFIX)]
80
-
81
- dep = self._parse_coordinate(coord_str, depth)
82
- if dep is None:
83
- return None
84
-
85
- return depth, is_leaf, dep
86
-
87
- def _parse_coordinate(self, coord_str: str, depth: int) -> Dependency | None:
88
- """
89
- Convert a Gradle coordinate string (with optional ``->`` resolution)
90
- into a :class:`Dependency`. Returns *None* if the string cannot be
91
- parsed.
92
-
93
- :author: Ron Webb
94
- :since: 1.0.0
95
- """
96
- # Resolve version arrows: "group:artifact:1.0 -> 2.0"
97
- arrow_match = _VERSION_ARROW_RE.search(coord_str)
98
- if arrow_match:
99
- resolved_version = arrow_match.group(1)
100
- # Strip the arrow portion from the coordinate
101
- coord_str = coord_str[: arrow_match.start()]
102
- else:
103
- resolved_version = None
104
-
105
- coord_match = _COORD_RE.match(coord_str.strip())
106
- if coord_match is None:
107
- _logger.debug("Could not parse coordinate: %s", coord_str)
108
- return None
109
-
110
- group_id = coord_match.group(1).strip()
111
- artifact_id = coord_match.group(2).strip()
112
- version = resolved_version or coord_match.group(3).strip()
113
-
114
- if not group_id or not artifact_id or not version:
115
- return None
116
-
117
- return Dependency(
118
- group_id=group_id,
119
- artifact_id=artifact_id,
120
- version=version,
121
- scope="runtime",
122
- depth=depth,
123
- )
1
+ """
2
+ gradle_dep_tree_parser module.
3
+
4
+ Parses the text output of ``gradle dependencies`` to reconstruct
5
+ the full dependency tree including transitive dependencies.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import re
12
+
13
+ from ..models.dependency import Dependency
14
+ from ..util.logger import setup_logger
15
+ from .base import DepTreeParser
16
+
17
+ __author__ = "Ron Webb"
18
+ __since__ = "1.0.0"
19
+
20
+ _logger = setup_logger(__name__)
21
+
22
+ # Matches the tree connector characters at the start of a dependency line.
23
+ # Each level of indentation is exactly 5 characters wide (" " or "| ").
24
+ _INDENT_UNIT = 5
25
+ _CONNECTOR_RE = re.compile(r"^((?:[|\\+]\s{3,4}|\s{5})*)([+\\])--- (.+)$")
26
+
27
+ # Matches a resolved version arrow, e.g. "1.0 -> 2.0", "RELEASE -> 4.16.0", or "artifact -> 4.16.0"
28
+ _VERSION_ARROW_RE = re.compile(r"\s*->\s*(\S+)$")
29
+
30
+ # Gradle coordinates without an explicit version: group:artifact (version comes from arrow)
31
+ _COORD_NO_VERSION_RE = re.compile(r"^([^:]+):([^:]+)$")
32
+
33
+ # Suffix appended to repeated subtree roots by Gradle
34
+ _REPEATED_SUFFIX = " (*)"
35
+
36
+ # Lines annotated as constraints (not actual dependencies)
37
+ _CONSTRAINT_SUFFIX = " (c)"
38
+
39
+ # Gradle coordinates: group:artifact:version
40
+ _COORD_RE = re.compile(r"^([^:]+):([^:]+):(.+)$")
41
+
42
+
43
+ class GradleDepTreeParser(DepTreeParser):
44
+ """
45
+ Parses the plain-text output of ``gradle dependencies`` and reconstructs
46
+ the dependency tree as a list of :class:`~java_dependency_analyzer.models.dependency.Dependency`
47
+ objects with nested ``transitive_dependencies``.
48
+
49
+ The parser is format-agnostic regarding the configuration name; it processes
50
+ the first dependency-tree block it encounters. Users should redirect the
51
+ output of the configuration they care about (e.g. ``runtimeClasspath``) into
52
+ a text file and pass that file here.
53
+
54
+ :author: Ron Webb
55
+ :since: 1.0.0
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ """
60
+ Initialise the parser with an empty version-resolution cache.
61
+
62
+ :author: Ron Webb
63
+ :since: 1.1.1
64
+ """
65
+ # Maps (group_id, artifact_id) -> resolved version from a -> arrow
66
+ self._resolutions: dict[tuple[str, str], str] = {}
67
+
68
+ def parse(self, file_path: str) -> list[Dependency]:
69
+ """
70
+ Reset the resolution cache and delegate to the base parser.
71
+
72
+ :author: Ron Webb
73
+ :since: 1.1.1
74
+ """
75
+ self._resolutions = {}
76
+ return super().parse(file_path)
77
+
78
+ # ------------------------------------------------------------------
79
+ # Private helpers
80
+ # ------------------------------------------------------------------
81
+
82
+ def _line_to_entry(self, line: str) -> tuple[int, bool, Dependency] | None:
83
+ """
84
+ Convert a single Gradle dep-tree line to a ``(depth, is_leaf, dep)``
85
+ entry, or return *None* to skip the line.
86
+
87
+ :author: Ron Webb
88
+ :since: 1.0.0
89
+ """
90
+ if line.rstrip().endswith(_CONSTRAINT_SUFFIX):
91
+ return None
92
+
93
+ match = _CONNECTOR_RE.match(line)
94
+ if match is None:
95
+ return None
96
+
97
+ indent_str, coord_str = match.group(1), match.group(3)
98
+ depth = len(indent_str) // _INDENT_UNIT
99
+
100
+ is_leaf = coord_str.endswith(_REPEATED_SUFFIX)
101
+ if is_leaf:
102
+ coord_str = coord_str[: -len(_REPEATED_SUFFIX)]
103
+
104
+ dep = self._parse_coordinate(coord_str, depth)
105
+ if dep is None:
106
+ return None
107
+
108
+ return depth, is_leaf, dep
109
+
110
+ def _parse_coordinate(self, coord_str: str, depth: int) -> Dependency | None:
111
+ """
112
+ Convert a Gradle coordinate string (with optional ``->`` resolution)
113
+ into a :class:`Dependency`. Returns *None* if the string cannot be
114
+ parsed.
115
+
116
+ :author: Ron Webb
117
+ :since: 1.0.0
118
+ """
119
+ # Resolve version arrows: "group:artifact:1.0 -> 2.0" or "group:artifact -> 2.0"
120
+ arrow_match = _VERSION_ARROW_RE.search(coord_str)
121
+ if arrow_match:
122
+ resolved_version = arrow_match.group(1)
123
+ # Strip the arrow portion from the coordinate
124
+ coord_str = coord_str[: arrow_match.start()]
125
+ else:
126
+ resolved_version = None
127
+
128
+ coord_str = coord_str.strip()
129
+ coord_match = _COORD_RE.match(coord_str)
130
+ if coord_match is not None:
131
+ group_id = coord_match.group(1).strip()
132
+ artifact_id = coord_match.group(2).strip()
133
+ version = resolved_version or coord_match.group(3).strip()
134
+ else:
135
+ # Handle "group:artifact -> version" (no version before the arrow)
136
+ no_ver_match = _COORD_NO_VERSION_RE.match(coord_str)
137
+ if no_ver_match and resolved_version:
138
+ group_id = no_ver_match.group(1).strip()
139
+ artifact_id = no_ver_match.group(2).strip()
140
+ version = resolved_version
141
+ else:
142
+ _logger.debug("Could not parse coordinate: %s", coord_str)
143
+ return None
144
+
145
+ if not group_id or not artifact_id or not version:
146
+ return None
147
+
148
+ # Store the resolved version the first time a -> arrow is seen for this artifact
149
+ if resolved_version:
150
+ self._resolutions[(group_id, artifact_id)] = resolved_version
151
+ # Apply any cached resolution so (*) repeated entries use the resolved version
152
+ version = self._resolutions.get((group_id, artifact_id), version)
153
+
154
+ return Dependency(
155
+ group_id=group_id,
156
+ artifact_id=artifact_id,
157
+ version=version,
158
+ scope="runtime",
159
+ depth=depth,
160
+ )
@@ -1,205 +1,224 @@
1
- """
2
- ghsa_scanner module.
3
-
4
- Queries the GitHub Advisory Database REST API to get vulnerability information
5
- for Maven packages.
6
-
7
- :author: Ron Webb
8
- :since: 1.0.0
9
- """
10
-
11
- import json
12
- import os
13
-
14
- import httpx
15
- from dotenv import load_dotenv
16
-
17
- from ..cache.vulnerability_cache import VulnerabilityCache
18
- from ..models.dependency import Dependency, Vulnerability
19
- from ..util.logger import setup_logger
20
- from .base import VulnerabilityScanner
21
-
22
- __author__ = "Ron Webb"
23
- __since__ = "1.0.0"
24
-
25
- load_dotenv()
26
-
27
- _logger = setup_logger(__name__)
28
-
29
- _GHSA_API_URL = "https://api.github.com/advisories"
30
- _ACCEPT_HEADER = "application/vnd.github+json"
31
- _API_VERSION_HEADER = "2022-11-28"
32
-
33
-
34
- class GhsaScanner(VulnerabilityScanner):
35
- """
36
- Queries the GitHub Advisory Database REST API (https://api.github.com/advisories)
37
- to find reviewed security advisories for a given Maven dependency version.
38
-
39
- Supports optional authentication via the ``GITHUB_TOKEN`` environment variable to
40
- increase the API rate limit from 60 to 5000 requests per hour.
41
-
42
- :author: Ron Webb
43
- :since: 1.0.0
44
- """
45
-
46
- def __init__(
47
- self,
48
- client: httpx.Client | None = None,
49
- cache: VulnerabilityCache | None = None,
50
- ) -> None:
51
- """
52
- Initialise the scanner with an optional shared httpx client and cache.
53
-
54
- If ``GITHUB_TOKEN`` is set in the environment, it is forwarded as a
55
- ``Bearer`` token to raise the GitHub API rate limit.
56
- When *cache* is provided, scan results are read from and written to it.
57
-
58
- :author: Ron Webb
59
- :since: 1.0.0
60
- """
61
- headers = {
62
- "Accept": _ACCEPT_HEADER,
63
- "X-GitHub-Api-Version": _API_VERSION_HEADER,
64
- }
65
- token = os.environ.get("GITHUB_TOKEN")
66
- if token:
67
- headers["Authorization"] = f"Bearer {token}"
68
-
69
- self._client = client or httpx.Client(timeout=30, headers=headers)
70
- self._cache = cache
71
-
72
- def scan(self, dependency: Dependency) -> list[Vulnerability]:
73
- """
74
- Query the GitHub Advisory Database for advisories affecting this dependency.
75
-
76
- Checks the cache first when one is configured; only calls the API on a
77
- cache miss and stores the raw response on success.
78
-
79
- Uses the ``affects`` query parameter with ``group:artifact@version`` notation
80
- and filters by ``ecosystem=maven`` and ``type=reviewed``.
81
-
82
- :author: Ron Webb
83
- :since: 1.0.0
84
- """
85
- _logger.debug("Querying GHSA for %s", dependency.coordinates)
86
- cached = self._get_cached("ghsa", dependency)
87
- if cached is not None:
88
- return self._apply_cache_source(cached, "ghsa")
89
-
90
- affects = f"{dependency.group_id}:{dependency.artifact_id}@{dependency.version}"
91
- params = {
92
- "ecosystem": "maven",
93
- "affects": affects,
94
- "type": "reviewed",
95
- }
96
- try:
97
- response = self._client.get(_GHSA_API_URL, params=params)
98
- if response.status_code == 429:
99
- _logger.warning(
100
- "GitHub Advisory API rate limit exceeded for %s",
101
- dependency.coordinates,
102
- )
103
- return []
104
- response.raise_for_status()
105
- data = response.json()
106
- except httpx.HTTPError as exc:
107
- _logger.warning("GHSA query failed for %s: %s", dependency.coordinates, exc)
108
- return []
109
-
110
- self._put_cached("ghsa", dependency, json.dumps(data))
111
- return self._parse_response(data)
112
-
113
- def _parse_response(self, data: list) -> list[Vulnerability]:
114
- """
115
- Parse the GitHub Advisory API response (a JSON array) into Vulnerability objects.
116
-
117
- :author: Ron Webb
118
- :since: 1.0.0
119
- """
120
- vulns: list[Vulnerability] = []
121
- for advisory in data:
122
- vuln_obj = self._parse_advisory(advisory)
123
- if vuln_obj is not None:
124
- vulns.append(vuln_obj)
125
- return vulns
126
-
127
- def _parse_advisory(self, advisory: dict) -> Vulnerability | None:
128
- """
129
- Convert a single GitHub Advisory dict into a Vulnerability object.
130
-
131
- The ``cve_id`` field prefers the CVE identifier when available and falls
132
- back to the GHSA identifier so every returned advisory has a unique ID.
133
-
134
- :author: Ron Webb
135
- :since: 1.0.0
136
- """
137
- ghsa_id = advisory.get("ghsa_id", "UNKNOWN")
138
- cve_id = advisory.get("cve_id") or ghsa_id
139
- summary = advisory.get("summary", "No summary available")
140
- severity = self._extract_severity(advisory)
141
- affected_versions = self._extract_affected_versions(advisory)
142
- reference_url = advisory.get("html_url", "")
143
-
144
- return Vulnerability(
145
- cve_id=cve_id,
146
- summary=summary,
147
- severity=severity,
148
- affected_versions=affected_versions,
149
- source="ghsa",
150
- reference_url=reference_url,
151
- )
152
-
153
- def _extract_severity(self, advisory: dict) -> str:
154
- """
155
- Extract a human-readable severity string from a GitHub Advisory entry.
156
-
157
- Tries, in order:
158
- 1. The top-level ``severity`` label (e.g. ``"high"``, ``"critical"``).
159
- 2. The CVSS v4 score from ``cvss_severities.cvss_v4.score``.
160
- 3. The CVSS v3 score from ``cvss_severities.cvss_v3.score``.
161
- 4. The legacy ``cvss.score`` field.
162
-
163
- :author: Ron Webb
164
- :since: 1.0.0
165
- """
166
- label = advisory.get("severity")
167
- if label and label not in ("", "unknown"):
168
- return label.upper()
169
-
170
- cvss_severities = advisory.get("cvss_severities", {})
171
- for key in ("cvss_v4", "cvss_v3"):
172
- score = (cvss_severities.get(key) or {}).get("score")
173
- if score is not None:
174
- return str(score)
175
-
176
- legacy_score = (advisory.get("cvss") or {}).get("score")
177
- if legacy_score is not None:
178
- return str(legacy_score)
179
-
180
- return "UNKNOWN"
181
-
182
- def _extract_affected_versions(self, advisory: dict) -> list[str]:
183
- """
184
- Extract affected version range strings from a GitHub Advisory entry.
185
-
186
- Each entry in ``vulnerabilities`` may carry a ``vulnerable_version_range``
187
- string (e.g. ``"< 2.17.0"`` or ``">= 2.0.0, < 2.15.0"``). The method
188
- splits compound ranges on commas so the returned list contains individual
189
- constraint tokens.
190
-
191
- :author: Ron Webb
192
- :since: 1.0.0
193
- """
194
- affected_versions: list[str] = []
195
- seen: set[str] = set()
196
- for vuln_entry in advisory.get("vulnerabilities", []):
197
- version_range = vuln_entry.get("vulnerable_version_range")
198
- if not version_range:
199
- continue
200
- for part in version_range.split(","):
201
- constraint = part.strip()
202
- if constraint and constraint not in seen:
203
- affected_versions.append(constraint)
204
- seen.add(constraint)
205
- return affected_versions
1
+ """
2
+ ghsa_scanner module.
3
+
4
+ Queries the GitHub Advisory Database REST API to get vulnerability information
5
+ for Maven packages.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import json
12
+ import os
13
+
14
+ import httpx
15
+ from dotenv import load_dotenv
16
+
17
+ from ..cache.vulnerability_cache import VulnerabilityCache
18
+ from ..models.dependency import Dependency, Vulnerability
19
+ from ..util.logger import setup_logger
20
+ from .base import VulnerabilityScanner
21
+
22
+ __author__ = "Ron Webb"
23
+ __since__ = "1.0.0"
24
+
25
+ load_dotenv()
26
+
27
+ _logger = setup_logger(__name__)
28
+
29
+ _GHSA_API_URL = "https://api.github.com/advisories"
30
+ _ACCEPT_HEADER = "application/vnd.github+json"
31
+ _API_VERSION_HEADER = "2022-11-28"
32
+
33
+
34
+ class GhsaScanner(VulnerabilityScanner):
35
+ """
36
+ Queries the GitHub Advisory Database REST API (https://api.github.com/advisories)
37
+ to find reviewed security advisories for a given Maven dependency version.
38
+
39
+ Supports optional authentication via the ``GITHUB_TOKEN`` environment variable to
40
+ increase the API rate limit from 60 to 5000 requests per hour.
41
+
42
+ :author: Ron Webb
43
+ :since: 1.0.0
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ client: httpx.Client | None = None,
49
+ cache: VulnerabilityCache | None = None,
50
+ ) -> None:
51
+ """
52
+ Initialise the scanner with an optional shared httpx client and cache.
53
+
54
+ If ``GITHUB_TOKEN`` is set in the environment, it is forwarded as a
55
+ ``Bearer`` token to raise the GitHub API rate limit.
56
+ When *cache* is provided, scan results are read from and written to it.
57
+
58
+ :author: Ron Webb
59
+ :since: 1.0.0
60
+ """
61
+ headers = {
62
+ "Accept": _ACCEPT_HEADER,
63
+ "X-GitHub-Api-Version": _API_VERSION_HEADER,
64
+ }
65
+ token = os.environ.get("GITHUB_TOKEN")
66
+ if token:
67
+ headers["Authorization"] = f"Bearer {token}"
68
+
69
+ self._client = client or httpx.Client(timeout=30, headers=headers)
70
+ self._cache = cache
71
+ self._rate_limited: bool = False
72
+
73
+ @property
74
+ def rate_limited(self) -> bool:
75
+ """
76
+ Return ``True`` once the GitHub Advisory API has responded with a
77
+ rate-limit error (HTTP 403 or 429) so callers can skip further requests.
78
+
79
+ :author: Ron Webb
80
+ :since: 1.1.1
81
+ """
82
+ return self._rate_limited
83
+
84
+ def scan(self, dependency: Dependency) -> list[Vulnerability]:
85
+ """
86
+ Query the GitHub Advisory Database for advisories affecting this dependency.
87
+
88
+ Checks the cache first when one is configured; only calls the API on a
89
+ cache miss and stores the raw response on success.
90
+
91
+ Uses the ``affects`` query parameter with ``group:artifact@version`` notation
92
+ and filters by ``ecosystem=maven`` and ``type=reviewed``.
93
+
94
+ :author: Ron Webb
95
+ :since: 1.0.0
96
+ """
97
+ _logger.debug("Querying GHSA for %s", dependency.coordinates)
98
+ cached = self._get_cached("ghsa", dependency)
99
+ if cached is not None:
100
+ return self._apply_cache_source(cached, "ghsa")
101
+
102
+ affects = f"{dependency.group_id}:{dependency.artifact_id}@{dependency.version}"
103
+ params = {
104
+ "ecosystem": "maven",
105
+ "affects": affects,
106
+ "type": "reviewed",
107
+ }
108
+ if self._rate_limited:
109
+ return []
110
+
111
+ try:
112
+ response = self._client.get(_GHSA_API_URL, params=params)
113
+ if response.status_code in (429, 403) and (
114
+ response.status_code == 429 or "rate limit" in response.text.lower()
115
+ ):
116
+ _logger.warning(
117
+ "GitHub Advisory API rate limit exceeded (HTTP %s); "
118
+ "disabling GHSA for this run",
119
+ response.status_code,
120
+ )
121
+ self._rate_limited = True
122
+ return []
123
+ response.raise_for_status()
124
+ data = response.json()
125
+ except httpx.HTTPError as exc:
126
+ _logger.warning("GHSA query failed for %s: %s", dependency.coordinates, exc)
127
+ return []
128
+
129
+ self._put_cached("ghsa", dependency, json.dumps(data))
130
+ return self._parse_response(data)
131
+
132
+ def _parse_response(self, data: list) -> list[Vulnerability]:
133
+ """
134
+ Parse the GitHub Advisory API response (a JSON array) into Vulnerability objects.
135
+
136
+ :author: Ron Webb
137
+ :since: 1.0.0
138
+ """
139
+ vulns: list[Vulnerability] = []
140
+ for advisory in data:
141
+ vuln_obj = self._parse_advisory(advisory)
142
+ if vuln_obj is not None:
143
+ vulns.append(vuln_obj)
144
+ return vulns
145
+
146
+ def _parse_advisory(self, advisory: dict) -> Vulnerability | None:
147
+ """
148
+ Convert a single GitHub Advisory dict into a Vulnerability object.
149
+
150
+ The ``cve_id`` field prefers the CVE identifier when available and falls
151
+ back to the GHSA identifier so every returned advisory has a unique ID.
152
+
153
+ :author: Ron Webb
154
+ :since: 1.0.0
155
+ """
156
+ ghsa_id = advisory.get("ghsa_id", "UNKNOWN")
157
+ cve_id = advisory.get("cve_id") or ghsa_id
158
+ summary = advisory.get("summary", "No summary available")
159
+ severity = self._extract_severity(advisory)
160
+ affected_versions = self._extract_affected_versions(advisory)
161
+ reference_url = advisory.get("html_url", "")
162
+
163
+ return Vulnerability(
164
+ cve_id=cve_id,
165
+ summary=summary,
166
+ severity=severity,
167
+ affected_versions=affected_versions,
168
+ source="ghsa",
169
+ reference_url=reference_url,
170
+ )
171
+
172
+ def _extract_severity(self, advisory: dict) -> str:
173
+ """
174
+ Extract a human-readable severity string from a GitHub Advisory entry.
175
+
176
+ Tries, in order:
177
+ 1. The top-level ``severity`` label (e.g. ``"high"``, ``"critical"``).
178
+ 2. The CVSS v4 score from ``cvss_severities.cvss_v4.score``.
179
+ 3. The CVSS v3 score from ``cvss_severities.cvss_v3.score``.
180
+ 4. The legacy ``cvss.score`` field.
181
+
182
+ :author: Ron Webb
183
+ :since: 1.0.0
184
+ """
185
+ label = advisory.get("severity")
186
+ if label and label not in ("", "unknown"):
187
+ return label.upper()
188
+
189
+ cvss_severities = advisory.get("cvss_severities", {})
190
+ for key in ("cvss_v4", "cvss_v3"):
191
+ score = (cvss_severities.get(key) or {}).get("score")
192
+ if score is not None:
193
+ return str(score)
194
+
195
+ legacy_score = (advisory.get("cvss") or {}).get("score")
196
+ if legacy_score is not None:
197
+ return str(legacy_score)
198
+
199
+ return "UNKNOWN"
200
+
201
+ def _extract_affected_versions(self, advisory: dict) -> list[str]:
202
+ """
203
+ Extract affected version range strings from a GitHub Advisory entry.
204
+
205
+ Each entry in ``vulnerabilities`` may carry a ``vulnerable_version_range``
206
+ string (e.g. ``"< 2.17.0"`` or ``">= 2.0.0, < 2.15.0"``). The method
207
+ splits compound ranges on commas so the returned list contains individual
208
+ constraint tokens.
209
+
210
+ :author: Ron Webb
211
+ :since: 1.0.0
212
+ """
213
+ affected_versions: list[str] = []
214
+ seen: set[str] = set()
215
+ for vuln_entry in advisory.get("vulnerabilities", []):
216
+ version_range = vuln_entry.get("vulnerable_version_range")
217
+ if not version_range:
218
+ continue
219
+ for part in version_range.split(","):
220
+ constraint = part.strip()
221
+ if constraint and constraint not in seen:
222
+ affected_versions.append(constraint)
223
+ seen.add(constraint)
224
+ return affected_versions
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "java-dependency-analyzer"
3
- version = "1.1.0"
3
+ version = "1.1.1"
4
4
  description = "Java Dependency Analyzer is a tool that inspects dependencies."
5
5
  authors = [
6
6
  {name = "Ron Webb",email = "ron@ronella.xyz"}