java-dependency-analyzer 1.0.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.
- java_dependency_analyzer/__init__.py +11 -0
- java_dependency_analyzer/cache/__init__.py +11 -0
- java_dependency_analyzer/cache/db.py +101 -0
- java_dependency_analyzer/cache/vulnerability_cache.py +156 -0
- java_dependency_analyzer/cli.py +394 -0
- java_dependency_analyzer/models/__init__.py +11 -0
- java_dependency_analyzer/models/dependency.py +80 -0
- java_dependency_analyzer/models/report.py +108 -0
- java_dependency_analyzer/parsers/__init__.py +11 -0
- java_dependency_analyzer/parsers/base.py +150 -0
- java_dependency_analyzer/parsers/gradle_dep_tree_parser.py +125 -0
- java_dependency_analyzer/parsers/gradle_parser.py +206 -0
- java_dependency_analyzer/parsers/maven_dep_tree_parser.py +123 -0
- java_dependency_analyzer/parsers/maven_parser.py +182 -0
- java_dependency_analyzer/reporters/__init__.py +11 -0
- java_dependency_analyzer/reporters/base.py +33 -0
- java_dependency_analyzer/reporters/html_reporter.py +82 -0
- java_dependency_analyzer/reporters/json_reporter.py +52 -0
- java_dependency_analyzer/reporters/templates/report.html +406 -0
- java_dependency_analyzer/resolvers/__init__.py +11 -0
- java_dependency_analyzer/resolvers/transitive.py +276 -0
- java_dependency_analyzer/scanners/__init__.py +11 -0
- java_dependency_analyzer/scanners/base.py +102 -0
- java_dependency_analyzer/scanners/ghsa_scanner.py +204 -0
- java_dependency_analyzer/scanners/osv_scanner.py +167 -0
- java_dependency_analyzer/util/__init__.py +11 -0
- java_dependency_analyzer/util/logger.py +48 -0
- java_dependency_analyzer-1.0.0.dist-info/METADATA +193 -0
- java_dependency_analyzer-1.0.0.dist-info/RECORD +31 -0
- java_dependency_analyzer-1.0.0.dist-info/WHEEL +4 -0
- java_dependency_analyzer-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dependency module.
|
|
3
|
+
|
|
4
|
+
Defines the core data models for dependencies and vulnerabilities.
|
|
5
|
+
|
|
6
|
+
:author: Ron Webb
|
|
7
|
+
:since: 1.0.0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
__author__ = "Ron Webb"
|
|
13
|
+
__since__ = "1.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Vulnerability:
|
|
18
|
+
"""
|
|
19
|
+
Represents a single vulnerability found in a dependency.
|
|
20
|
+
|
|
21
|
+
:author: Ron Webb
|
|
22
|
+
:since: 1.0.0
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
cve_id: str
|
|
26
|
+
summary: str
|
|
27
|
+
severity: str
|
|
28
|
+
affected_versions: list[str] = field(default_factory=list)
|
|
29
|
+
source: str = "osv"
|
|
30
|
+
reference_url: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Dependency:
|
|
35
|
+
"""
|
|
36
|
+
Represents a Java dependency with group, artifact, and version coordinates.
|
|
37
|
+
|
|
38
|
+
:author: Ron Webb
|
|
39
|
+
:since: 1.0.0
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
group_id: str
|
|
43
|
+
artifact_id: str
|
|
44
|
+
version: str
|
|
45
|
+
scope: str = "compile"
|
|
46
|
+
depth: int = 0
|
|
47
|
+
transitive_dependencies: list["Dependency"] = field(default_factory=list)
|
|
48
|
+
vulnerabilities: list[Vulnerability] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def coordinates(self) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Return Maven coordinates string in group:artifact:version format.
|
|
54
|
+
|
|
55
|
+
:author: Ron Webb
|
|
56
|
+
:since: 1.0.0
|
|
57
|
+
"""
|
|
58
|
+
return f"{self.group_id}:{self.artifact_id}:{self.version}"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def maven_path(self) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Return the relative path to this artifact in a Maven repository.
|
|
64
|
+
|
|
65
|
+
:author: Ron Webb
|
|
66
|
+
:since: 1.0.0
|
|
67
|
+
"""
|
|
68
|
+
group_path = self.group_id.replace(".", "/")
|
|
69
|
+
return f"{group_path}/{self.artifact_id}/{self.version}"
|
|
70
|
+
|
|
71
|
+
def has_vulnerabilities(self) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Return True if this dependency or any transitive dependency has vulnerabilities.
|
|
74
|
+
|
|
75
|
+
:author: Ron Webb
|
|
76
|
+
:since: 1.0.0
|
|
77
|
+
"""
|
|
78
|
+
if self.vulnerabilities:
|
|
79
|
+
return True
|
|
80
|
+
return any(dep.has_vulnerabilities() for dep in self.transitive_dependencies)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
report module.
|
|
3
|
+
|
|
4
|
+
Defines the ScanResult model for aggregating vulnerability scan findings.
|
|
5
|
+
|
|
6
|
+
:author: Ron Webb
|
|
7
|
+
:since: 1.0.0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from .dependency import Dependency
|
|
14
|
+
|
|
15
|
+
__author__ = "Ron Webb"
|
|
16
|
+
__since__ = "1.0.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ScanResult:
|
|
21
|
+
"""
|
|
22
|
+
Aggregates all findings from a dependency vulnerability scan.
|
|
23
|
+
|
|
24
|
+
:author: Ron Webb
|
|
25
|
+
:since: 1.0.0
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
source_file: str
|
|
29
|
+
scanned_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
30
|
+
dependencies: list[Dependency] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def total_dependencies(self) -> int:
|
|
34
|
+
"""
|
|
35
|
+
Return the total count of all direct and transitive dependencies.
|
|
36
|
+
|
|
37
|
+
:author: Ron Webb
|
|
38
|
+
:since: 1.0.0
|
|
39
|
+
"""
|
|
40
|
+
return self._count_dependencies(self.dependencies)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def vulnerable_dependencies(self) -> list[Dependency]:
|
|
44
|
+
"""
|
|
45
|
+
Return a deduplicated flat list of all dependencies that have vulnerabilities.
|
|
46
|
+
|
|
47
|
+
Two nodes with identical (group_id, artifact_id, version) are treated as the
|
|
48
|
+
same dependency regardless of where they appear in the tree.
|
|
49
|
+
|
|
50
|
+
:author: Ron Webb
|
|
51
|
+
:since: 1.0.0
|
|
52
|
+
"""
|
|
53
|
+
result: list[Dependency] = []
|
|
54
|
+
seen: set[tuple[str, str, str]] = set()
|
|
55
|
+
self._collect_vulnerable(self.dependencies, result, seen)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def total_vulnerabilities(self) -> int:
|
|
60
|
+
"""
|
|
61
|
+
Return the total number of individual vulnerabilities found across unique dependencies.
|
|
62
|
+
|
|
63
|
+
:author: Ron Webb
|
|
64
|
+
:since: 1.0.0
|
|
65
|
+
"""
|
|
66
|
+
return sum(len(dep.vulnerabilities) for dep in self.vulnerable_dependencies)
|
|
67
|
+
|
|
68
|
+
def _count_dependencies(self, deps: list[Dependency]) -> int:
|
|
69
|
+
"""
|
|
70
|
+
Recursively count all dependencies in the tree.
|
|
71
|
+
|
|
72
|
+
:author: Ron Webb
|
|
73
|
+
:since: 1.0.0
|
|
74
|
+
"""
|
|
75
|
+
count = len(deps)
|
|
76
|
+
for dep in deps:
|
|
77
|
+
count += self._count_dependencies(dep.transitive_dependencies)
|
|
78
|
+
return count
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _dep_key(dep: Dependency) -> tuple[str, str, str]:
|
|
82
|
+
"""
|
|
83
|
+
Return a deduplication key for a dependency based on its coordinates.
|
|
84
|
+
|
|
85
|
+
:author: Ron Webb
|
|
86
|
+
:since: 1.0.0
|
|
87
|
+
"""
|
|
88
|
+
return (dep.group_id, dep.artifact_id, dep.version)
|
|
89
|
+
|
|
90
|
+
def _collect_vulnerable(
|
|
91
|
+
self,
|
|
92
|
+
deps: list[Dependency],
|
|
93
|
+
result: list[Dependency],
|
|
94
|
+
seen: set[tuple[str, str, str]],
|
|
95
|
+
) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Recursively collect unique dependencies that have vulnerabilities.
|
|
98
|
+
|
|
99
|
+
:author: Ron Webb
|
|
100
|
+
:since: 1.0.0
|
|
101
|
+
"""
|
|
102
|
+
for dep in deps:
|
|
103
|
+
if dep.vulnerabilities:
|
|
104
|
+
key = self._dep_key(dep)
|
|
105
|
+
if key not in seen:
|
|
106
|
+
seen.add(key)
|
|
107
|
+
result.append(dep)
|
|
108
|
+
self._collect_vulnerable(dep.transitive_dependencies, result, seen)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
base module.
|
|
3
|
+
|
|
4
|
+
Defines the abstract base class for dependency file parsers and shared
|
|
5
|
+
tree-building utilities.
|
|
6
|
+
|
|
7
|
+
:author: Ron Webb
|
|
8
|
+
:since: 1.0.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..models.dependency import Dependency
|
|
16
|
+
from ..util.logger import setup_logger
|
|
17
|
+
|
|
18
|
+
__author__ = "Ron Webb"
|
|
19
|
+
__since__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
_logger = setup_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Runtime scopes that contribute to the executable classpath
|
|
24
|
+
RUNTIME_SCOPES = frozenset({"compile", "runtime", "implementation", "api", "runtimeOnly"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def attach_node(
|
|
28
|
+
dep: Dependency,
|
|
29
|
+
depth: int,
|
|
30
|
+
is_leaf: bool,
|
|
31
|
+
roots: list[Dependency],
|
|
32
|
+
stack: list[Dependency | None],
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Attach *dep* to the correct position in the dependency tree represented
|
|
36
|
+
by *roots* (top-level nodes) and *stack* (depth-indexed parent cursor).
|
|
37
|
+
|
|
38
|
+
*stack[d]* holds the :class:`Dependency` that is the current parent at
|
|
39
|
+
depth *d*. After attaching *dep*, the stack is updated so that
|
|
40
|
+
``stack[depth]`` points to *dep* (or ``None`` if *dep* is a leaf).
|
|
41
|
+
|
|
42
|
+
:author: Ron Webb
|
|
43
|
+
:since: 1.0.0
|
|
44
|
+
"""
|
|
45
|
+
if depth == 0:
|
|
46
|
+
roots.append(dep)
|
|
47
|
+
else:
|
|
48
|
+
parent_depth = depth - 1
|
|
49
|
+
if parent_depth < len(stack) and stack[parent_depth] is not None:
|
|
50
|
+
stack[parent_depth].transitive_dependencies.append(dep) # type: ignore[union-attr]
|
|
51
|
+
|
|
52
|
+
new_entry: Dependency | None = None if is_leaf else dep
|
|
53
|
+
if depth < len(stack):
|
|
54
|
+
stack[depth] = new_entry
|
|
55
|
+
else:
|
|
56
|
+
while len(stack) < depth:
|
|
57
|
+
stack.append(None)
|
|
58
|
+
stack.append(new_entry)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_tree_from_lines(
|
|
62
|
+
lines: list[str],
|
|
63
|
+
line_to_entry: Callable[[str], tuple[int, bool, Dependency] | None],
|
|
64
|
+
) -> list[Dependency]:
|
|
65
|
+
"""
|
|
66
|
+
Build a nested dependency tree from raw text *lines*.
|
|
67
|
+
|
|
68
|
+
Each line is passed through *line_to_entry*, which should return a
|
|
69
|
+
``(depth, is_leaf, dep)`` tuple or *None* to skip the line. The
|
|
70
|
+
resulting entries are assembled into a tree using :func:`attach_node`.
|
|
71
|
+
|
|
72
|
+
This function is the shared core of both :class:`GradleDepTreeParser`
|
|
73
|
+
and :class:`MavenDepTreeParser`.
|
|
74
|
+
|
|
75
|
+
:author: Ron Webb
|
|
76
|
+
:since: 1.0.0
|
|
77
|
+
"""
|
|
78
|
+
roots: list[Dependency] = []
|
|
79
|
+
stack: list[Dependency | None] = []
|
|
80
|
+
for line in lines:
|
|
81
|
+
entry = line_to_entry(line)
|
|
82
|
+
if entry is None:
|
|
83
|
+
continue
|
|
84
|
+
depth, is_leaf, dep = entry
|
|
85
|
+
attach_node(dep, depth, is_leaf, roots, stack)
|
|
86
|
+
return roots
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DependencyParser(ABC):
|
|
90
|
+
"""
|
|
91
|
+
Abstract base class for all dependency file parsers.
|
|
92
|
+
|
|
93
|
+
:author: Ron Webb
|
|
94
|
+
:since: 1.0.0
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def parse(self, file_path: str) -> list[Dependency]:
|
|
99
|
+
"""
|
|
100
|
+
Parse the given build file and return a list of direct dependencies.
|
|
101
|
+
|
|
102
|
+
Only runtime-relevant scopes are returned (compile, runtime,
|
|
103
|
+
implementation, api, runtimeOnly).
|
|
104
|
+
|
|
105
|
+
:author: Ron Webb
|
|
106
|
+
:since: 1.0.0
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DepTreeParser(DependencyParser):
|
|
111
|
+
"""
|
|
112
|
+
Intermediate base class for parsers that read pre-generated dependency
|
|
113
|
+
tree text files (e.g. ``gradle dependencies`` or ``mvn dependency:tree``).
|
|
114
|
+
|
|
115
|
+
Subclasses must implement :meth:`_line_to_entry` to convert a single
|
|
116
|
+
text line into a ``(depth, is_leaf, dep)`` entry.
|
|
117
|
+
|
|
118
|
+
:author: Ron Webb
|
|
119
|
+
:since: 1.0.0
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def parse(self, file_path: str) -> list[Dependency]:
|
|
123
|
+
"""
|
|
124
|
+
Read *file_path* and build a dependency tree by passing each line
|
|
125
|
+
through :meth:`_line_to_entry`.
|
|
126
|
+
|
|
127
|
+
:author: Ron Webb
|
|
128
|
+
:since: 1.0.0
|
|
129
|
+
"""
|
|
130
|
+
_logger.info("Parsing dependency tree from '%s'", file_path)
|
|
131
|
+
try:
|
|
132
|
+
content = Path(file_path).read_text(encoding="utf-8")
|
|
133
|
+
except OSError as exc:
|
|
134
|
+
_logger.error("Failed to read file: %s", exc)
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
lines = content.splitlines()
|
|
138
|
+
return build_tree_from_lines(lines, self._line_to_entry)
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def _line_to_entry(
|
|
142
|
+
self, line: str
|
|
143
|
+
) -> tuple[int, bool, Dependency] | None:
|
|
144
|
+
"""
|
|
145
|
+
Convert a single dependency-tree text line into a
|
|
146
|
+
``(depth, is_leaf, dep)`` tuple, or return *None* to skip the line.
|
|
147
|
+
|
|
148
|
+
:author: Ron Webb
|
|
149
|
+
:since: 1.0.0
|
|
150
|
+
"""
|
|
@@ -0,0 +1,125 @@
|
|
|
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(
|
|
60
|
+
self, line: str
|
|
61
|
+
) -> tuple[int, bool, Dependency] | None:
|
|
62
|
+
"""
|
|
63
|
+
Convert a single Gradle dep-tree line to a ``(depth, is_leaf, dep)``
|
|
64
|
+
entry, or return *None* to skip the line.
|
|
65
|
+
|
|
66
|
+
:author: Ron Webb
|
|
67
|
+
:since: 1.0.0
|
|
68
|
+
"""
|
|
69
|
+
if line.rstrip().endswith(_CONSTRAINT_SUFFIX):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
match = _CONNECTOR_RE.match(line)
|
|
73
|
+
if match is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
indent_str, coord_str = match.group(1), match.group(3)
|
|
77
|
+
depth = len(indent_str) // _INDENT_UNIT
|
|
78
|
+
|
|
79
|
+
is_leaf = coord_str.endswith(_REPEATED_SUFFIX)
|
|
80
|
+
if is_leaf:
|
|
81
|
+
coord_str = coord_str[: -len(_REPEATED_SUFFIX)]
|
|
82
|
+
|
|
83
|
+
dep = self._parse_coordinate(coord_str, depth)
|
|
84
|
+
if dep is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
return depth, is_leaf, dep
|
|
88
|
+
|
|
89
|
+
def _parse_coordinate(self, coord_str: str, depth: int) -> Dependency | None:
|
|
90
|
+
"""
|
|
91
|
+
Convert a Gradle coordinate string (with optional ``->`` resolution)
|
|
92
|
+
into a :class:`Dependency`. Returns *None* if the string cannot be
|
|
93
|
+
parsed.
|
|
94
|
+
|
|
95
|
+
:author: Ron Webb
|
|
96
|
+
:since: 1.0.0
|
|
97
|
+
"""
|
|
98
|
+
# Resolve version arrows: "group:artifact:1.0 -> 2.0"
|
|
99
|
+
arrow_match = _VERSION_ARROW_RE.search(coord_str)
|
|
100
|
+
if arrow_match:
|
|
101
|
+
resolved_version = arrow_match.group(1)
|
|
102
|
+
# Strip the arrow portion from the coordinate
|
|
103
|
+
coord_str = coord_str[: arrow_match.start()]
|
|
104
|
+
else:
|
|
105
|
+
resolved_version = None
|
|
106
|
+
|
|
107
|
+
coord_match = _COORD_RE.match(coord_str.strip())
|
|
108
|
+
if coord_match is None:
|
|
109
|
+
_logger.debug("Could not parse coordinate: %s", coord_str)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
group_id = coord_match.group(1).strip()
|
|
113
|
+
artifact_id = coord_match.group(2).strip()
|
|
114
|
+
version = resolved_version or coord_match.group(3).strip()
|
|
115
|
+
|
|
116
|
+
if not group_id or not artifact_id or not version:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
return Dependency(
|
|
120
|
+
group_id=group_id,
|
|
121
|
+
artifact_id=artifact_id,
|
|
122
|
+
version=version,
|
|
123
|
+
scope="runtime",
|
|
124
|
+
depth=depth,
|
|
125
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gradle_parser module.
|
|
3
|
+
|
|
4
|
+
Parses Gradle build files (build.gradle and build.gradle.kts) to extract
|
|
5
|
+
runtime dependencies using regex-based analysis.
|
|
6
|
+
|
|
7
|
+
:author: Ron Webb
|
|
8
|
+
:since: 1.0.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..models.dependency import Dependency
|
|
15
|
+
from ..util.logger import setup_logger
|
|
16
|
+
from .base import DependencyParser, RUNTIME_SCOPES
|
|
17
|
+
|
|
18
|
+
__author__ = "Ron Webb"
|
|
19
|
+
__since__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
_logger = setup_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Matches Groovy DSL shorthand: implementation 'group:artifact:version'
|
|
24
|
+
_GROOVY_SHORTHAND = re.compile(
|
|
25
|
+
r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s+['"]"""
|
|
26
|
+
r"""(?P<group>[^:'"]+):(?P<artifact>[^:'"]+):(?P<version>[^:'"]+)['"]""",
|
|
27
|
+
re.MULTILINE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Matches Kotlin DSL shorthand: implementation("group:artifact:version")
|
|
31
|
+
_KOTLIN_SHORTHAND = re.compile(
|
|
32
|
+
r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s*\("""
|
|
33
|
+
r"""["'](?P<group>[^:'"]+):(?P<artifact>[^:'"]+):(?P<version>[^:'"]+)["']\)""",
|
|
34
|
+
re.MULTILINE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Matches Groovy block syntax:
|
|
38
|
+
# implementation group: 'g', name: 'a', version: 'v'
|
|
39
|
+
_GROOVY_BLOCK = re.compile(
|
|
40
|
+
r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s+"""
|
|
41
|
+
r"""group:\s*['"](?P<group>[^'"]+)['"]\s*,\s*name:\s*"""
|
|
42
|
+
r"""['"](?P<artifact>[^'"]+)['"]\s*,\s*version:\s*['"](?P<version>[^'"]+)['"]""",
|
|
43
|
+
re.MULTILINE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Matches Groovy ext { } block content (no nested braces)
|
|
47
|
+
_EXT_BLOCK_PATTERN = re.compile(r"\bext\s*\{([^}]*)\}", re.DOTALL)
|
|
48
|
+
|
|
49
|
+
# Matches simple key = 'value' or key = "value" assignments inside ext blocks
|
|
50
|
+
_EXT_PROPERTY_PATTERN = re.compile(
|
|
51
|
+
r"""^[ \t]*(\w+)\s*=\s*['"]([^'"]+)['"]""",
|
|
52
|
+
re.MULTILINE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Matches Groovy `def varName = 'value'` or Kotlin `val varName = "value"`
|
|
56
|
+
_DEF_VAL_PATTERN = re.compile(
|
|
57
|
+
r"""(?:^|\s)(?:def|val)\s+(\w+)\s*=\s*["']([^"']+)["']""",
|
|
58
|
+
re.MULTILINE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Matches Kotlin block syntax:
|
|
62
|
+
# implementation(group = "g", name = "a", version = "v")
|
|
63
|
+
_KOTLIN_BLOCK = re.compile(
|
|
64
|
+
r"""(?:^|\s)(?P<config>implementation|api|compile|runtimeOnly|runtime)\s*\(\s*"""
|
|
65
|
+
r"""group\s*=\s*["'](?P<group>[^'"]+)["']\s*,\s*name\s*=\s*"""
|
|
66
|
+
r"""["'](?P<artifact>[^'"]+)["']\s*,\s*version\s*=\s*["'](?P<version>[^'"]+)["']\s*\)""",
|
|
67
|
+
re.MULTILINE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GradleParser(DependencyParser):
|
|
72
|
+
"""
|
|
73
|
+
Parses Gradle build files (Groovy DSL and Kotlin DSL) to extract runtime dependencies.
|
|
74
|
+
|
|
75
|
+
Supports both shorthand notation and named-parameter block notation.
|
|
76
|
+
Handles build.gradle (Groovy) and build.gradle.kts (Kotlin DSL).
|
|
77
|
+
|
|
78
|
+
:author: Ron Webb
|
|
79
|
+
:since: 1.0.0
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def parse(self, file_path: str) -> list[Dependency]:
|
|
83
|
+
"""
|
|
84
|
+
Parse the given Gradle build file and return a list of direct dependencies.
|
|
85
|
+
|
|
86
|
+
:author: Ron Webb
|
|
87
|
+
:since: 1.0.0
|
|
88
|
+
"""
|
|
89
|
+
_logger.info("Parsing Gradle build file: %s", file_path)
|
|
90
|
+
path = Path(file_path)
|
|
91
|
+
try:
|
|
92
|
+
content = path.read_text(encoding="utf-8")
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
_logger.error("Failed to read Gradle file: %s", exc)
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
is_kotlin_dsl = path.suffix == ".kts"
|
|
98
|
+
content = self._strip_comments(content, is_kotlin_dsl)
|
|
99
|
+
ext_props = self._extract_ext_properties(content)
|
|
100
|
+
if ext_props:
|
|
101
|
+
content = self._resolve_variables(content, ext_props)
|
|
102
|
+
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
deps: list[Dependency] = []
|
|
105
|
+
|
|
106
|
+
for dep in self._extract_all(content):
|
|
107
|
+
key = dep.coordinates
|
|
108
|
+
if key not in seen:
|
|
109
|
+
seen.add(key)
|
|
110
|
+
deps.append(dep)
|
|
111
|
+
|
|
112
|
+
_logger.info("Found %d unique dependencies in %s", len(deps), file_path)
|
|
113
|
+
return deps
|
|
114
|
+
|
|
115
|
+
def _extract_ext_properties(self, content: str) -> dict[str, str]:
|
|
116
|
+
"""
|
|
117
|
+
Extract simple string variable assignments from ``ext {}`` blocks and
|
|
118
|
+
top-level ``def``/``val`` declarations.
|
|
119
|
+
|
|
120
|
+
Returns a mapping of variable name to its string value.
|
|
121
|
+
|
|
122
|
+
:author: Ron Webb
|
|
123
|
+
:since: 1.0.0
|
|
124
|
+
"""
|
|
125
|
+
props: dict[str, str] = {}
|
|
126
|
+
for block_match in _EXT_BLOCK_PATTERN.finditer(content):
|
|
127
|
+
for prop_match in _EXT_PROPERTY_PATTERN.finditer(block_match.group(1)):
|
|
128
|
+
props[prop_match.group(1)] = prop_match.group(2)
|
|
129
|
+
for def_match in _DEF_VAL_PATTERN.finditer(content):
|
|
130
|
+
props[def_match.group(1)] = def_match.group(2)
|
|
131
|
+
return props
|
|
132
|
+
|
|
133
|
+
def _resolve_variables(self, content: str, props: dict[str, str]) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Substitute ``${varName}`` placeholders in *content* using *props*.
|
|
136
|
+
|
|
137
|
+
Unrecognised variable references are left unchanged.
|
|
138
|
+
|
|
139
|
+
:author: Ron Webb
|
|
140
|
+
:since: 1.0.0
|
|
141
|
+
"""
|
|
142
|
+
def _replacer(match: re.Match) -> str:
|
|
143
|
+
return props.get(match.group(1), match.group(0))
|
|
144
|
+
|
|
145
|
+
return re.sub(r"\$\{(\w+)\}", _replacer, content)
|
|
146
|
+
|
|
147
|
+
def _strip_comments(self, content: str, is_kotlin_dsl: bool) -> str: # pylint: disable=unused-argument
|
|
148
|
+
"""
|
|
149
|
+
Remove single-line (//) and block (/* */) comments from the file content.
|
|
150
|
+
|
|
151
|
+
:author: Ron Webb
|
|
152
|
+
:since: 1.0.0
|
|
153
|
+
"""
|
|
154
|
+
# Remove block comments
|
|
155
|
+
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
|
|
156
|
+
# Remove single-line comments
|
|
157
|
+
content = re.sub(r"//.*", "", content)
|
|
158
|
+
return content
|
|
159
|
+
|
|
160
|
+
def _extract_all(self, content: str) -> list[Dependency]:
|
|
161
|
+
"""
|
|
162
|
+
Run all regex patterns against the content and collect matches.
|
|
163
|
+
|
|
164
|
+
:author: Ron Webb
|
|
165
|
+
:since: 1.0.0
|
|
166
|
+
"""
|
|
167
|
+
deps: list[Dependency] = []
|
|
168
|
+
for pattern in (_GROOVY_SHORTHAND, _KOTLIN_SHORTHAND, _GROOVY_BLOCK, _KOTLIN_BLOCK):
|
|
169
|
+
for match in pattern.finditer(content):
|
|
170
|
+
dep = self._match_to_dependency(match)
|
|
171
|
+
if dep is not None:
|
|
172
|
+
deps.append(dep)
|
|
173
|
+
return deps
|
|
174
|
+
|
|
175
|
+
def _match_to_dependency(self, match: re.Match) -> Dependency | None:
|
|
176
|
+
"""
|
|
177
|
+
Convert a regex match object to a Dependency instance.
|
|
178
|
+
|
|
179
|
+
Returns None if the configuration scope is not runtime-relevant.
|
|
180
|
+
|
|
181
|
+
:author: Ron Webb
|
|
182
|
+
:since: 1.0.0
|
|
183
|
+
"""
|
|
184
|
+
config = match.group("config")
|
|
185
|
+
group = match.group("group").strip()
|
|
186
|
+
artifact = match.group("artifact").strip()
|
|
187
|
+
version = match.group("version").strip()
|
|
188
|
+
|
|
189
|
+
# Normalise scope name: 'compile' -> 'compile', 'implementation' -> 'implementation'
|
|
190
|
+
scope = config if config in RUNTIME_SCOPES else "compile"
|
|
191
|
+
|
|
192
|
+
if not group or not artifact or not version:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
# Skip variable references that couldn't be resolved
|
|
196
|
+
if "$" in version:
|
|
197
|
+
_logger.debug("Skipping %s:%s — unresolved version: %s", group, artifact, version)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
_logger.debug("Found dependency: %s:%s:%s (scope=%s)", group, artifact, version, scope)
|
|
201
|
+
return Dependency(
|
|
202
|
+
group_id=group,
|
|
203
|
+
artifact_id=artifact,
|
|
204
|
+
version=version,
|
|
205
|
+
scope=scope,
|
|
206
|
+
)
|