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,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
db module.
|
|
3
|
+
|
|
4
|
+
Manages the SQLite database lifecycle: path resolution, connection,
|
|
5
|
+
schema creation, and deletion.
|
|
6
|
+
|
|
7
|
+
:author: Ron Webb
|
|
8
|
+
:since: 1.0.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..util.logger import setup_logger
|
|
15
|
+
|
|
16
|
+
__author__ = "Ron Webb"
|
|
17
|
+
__since__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
_logger = setup_logger(__name__)
|
|
20
|
+
|
|
21
|
+
_DB_DIR = Path.home() / ".jda"
|
|
22
|
+
_DB_FILE = "cache.db"
|
|
23
|
+
|
|
24
|
+
_CREATE_TABLE_SQL = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS vulnerability_cache (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
source TEXT NOT NULL,
|
|
28
|
+
group_id TEXT NOT NULL,
|
|
29
|
+
artifact_id TEXT NOT NULL,
|
|
30
|
+
version TEXT NOT NULL,
|
|
31
|
+
payload TEXT NOT NULL,
|
|
32
|
+
cached_at TEXT NOT NULL,
|
|
33
|
+
UNIQUE(source, group_id, artifact_id, version)
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_CREATE_INDEX_SQL = """
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_cache_lookup
|
|
39
|
+
ON vulnerability_cache(group_id, artifact_id, version, source)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_db_path() -> Path:
|
|
44
|
+
"""
|
|
45
|
+
Return the absolute path to the SQLite cache database file.
|
|
46
|
+
|
|
47
|
+
The default location is ``~/.jda/cache.db``.
|
|
48
|
+
|
|
49
|
+
:author: Ron Webb
|
|
50
|
+
:since: 1.0.0
|
|
51
|
+
"""
|
|
52
|
+
return _DB_DIR / _DB_FILE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_connection() -> sqlite3.Connection:
|
|
56
|
+
"""
|
|
57
|
+
Open (and initialise) the SQLite database, creating the parent directory
|
|
58
|
+
and schema if they do not yet exist.
|
|
59
|
+
|
|
60
|
+
Returns a ``sqlite3.Connection`` with ``check_same_thread=False`` so the
|
|
61
|
+
connection can be shared within a single CLI invocation.
|
|
62
|
+
|
|
63
|
+
:author: Ron Webb
|
|
64
|
+
:since: 1.0.0
|
|
65
|
+
"""
|
|
66
|
+
db_path = get_db_path()
|
|
67
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
_logger.debug("Opening cache database at %s", db_path)
|
|
69
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
70
|
+
_initialise_schema(conn)
|
|
71
|
+
return conn
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _initialise_schema(conn: sqlite3.Connection) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Create the cache table and lookup index when they do not already exist.
|
|
77
|
+
|
|
78
|
+
:author: Ron Webb
|
|
79
|
+
:since: 1.0.0
|
|
80
|
+
"""
|
|
81
|
+
with conn:
|
|
82
|
+
conn.execute(_CREATE_TABLE_SQL)
|
|
83
|
+
conn.execute(_CREATE_INDEX_SQL)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def delete_database() -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Delete the SQLite cache database file.
|
|
89
|
+
|
|
90
|
+
Returns ``True`` if the file was deleted, ``False`` if it did not exist.
|
|
91
|
+
|
|
92
|
+
:author: Ron Webb
|
|
93
|
+
:since: 1.0.0
|
|
94
|
+
"""
|
|
95
|
+
db_path = get_db_path()
|
|
96
|
+
if db_path.exists():
|
|
97
|
+
db_path.unlink()
|
|
98
|
+
_logger.info("Deleted cache database at %s", db_path)
|
|
99
|
+
return True
|
|
100
|
+
_logger.debug("Cache database not found at %s, nothing to delete", db_path)
|
|
101
|
+
return False
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vulnerability_cache module.
|
|
3
|
+
|
|
4
|
+
Provides a SQLite-backed cache for vulnerability scan API responses.
|
|
5
|
+
Cache entries expire after a configurable number of days (default: 7).
|
|
6
|
+
|
|
7
|
+
:author: Ron Webb
|
|
8
|
+
:since: 1.0.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
|
|
14
|
+
from ..util.logger import setup_logger
|
|
15
|
+
from .db import get_connection
|
|
16
|
+
|
|
17
|
+
__author__ = "Ron Webb"
|
|
18
|
+
__since__ = "1.0.0"
|
|
19
|
+
|
|
20
|
+
_logger = setup_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_DEFAULT_TTL_DAYS = 7
|
|
23
|
+
|
|
24
|
+
_SELECT_SQL = """
|
|
25
|
+
SELECT payload, cached_at FROM vulnerability_cache
|
|
26
|
+
WHERE source = ? AND group_id = ? AND artifact_id = ? AND version = ?
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_UPSERT_SQL = """
|
|
30
|
+
INSERT INTO vulnerability_cache(source, group_id, artifact_id, version, payload, cached_at)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
32
|
+
ON CONFLICT(source, group_id, artifact_id, version)
|
|
33
|
+
DO UPDATE SET payload = excluded.payload, cached_at = excluded.cached_at
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_DELETE_SQL = """
|
|
37
|
+
DELETE FROM vulnerability_cache
|
|
38
|
+
WHERE source = ? AND group_id = ? AND artifact_id = ? AND version = ?
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class VulnerabilityCache:
|
|
43
|
+
"""
|
|
44
|
+
SQLite-backed cache for raw vulnerability API payloads.
|
|
45
|
+
|
|
46
|
+
Each entry is keyed by ``(source, group_id, artifact_id, version)`` and stores
|
|
47
|
+
the raw JSON response payload string. Entries older than ``ttl_days`` are
|
|
48
|
+
treated as expired and will not be returned.
|
|
49
|
+
|
|
50
|
+
:author: Ron Webb
|
|
51
|
+
:since: 1.0.0
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
connection: sqlite3.Connection | None = None,
|
|
57
|
+
ttl_days: int = _DEFAULT_TTL_DAYS,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Initialise the cache with an optional SQLite connection and TTL.
|
|
61
|
+
|
|
62
|
+
When *connection* is ``None`` the default on-disk database is used.
|
|
63
|
+
|
|
64
|
+
:author: Ron Webb
|
|
65
|
+
:since: 1.0.0
|
|
66
|
+
"""
|
|
67
|
+
self._conn = connection or get_connection()
|
|
68
|
+
self._ttl_days = ttl_days
|
|
69
|
+
|
|
70
|
+
def get(
|
|
71
|
+
self,
|
|
72
|
+
source: str,
|
|
73
|
+
group_id: str,
|
|
74
|
+
artifact_id: str,
|
|
75
|
+
version: str,
|
|
76
|
+
) -> str | None:
|
|
77
|
+
"""
|
|
78
|
+
Return the cached JSON payload for the given key, or ``None`` when there
|
|
79
|
+
is no valid (non-expired) entry.
|
|
80
|
+
|
|
81
|
+
:author: Ron Webb
|
|
82
|
+
:since: 1.0.0
|
|
83
|
+
"""
|
|
84
|
+
cursor = self._conn.execute(_SELECT_SQL, (source, group_id, artifact_id, version))
|
|
85
|
+
row = cursor.fetchone()
|
|
86
|
+
if row is None:
|
|
87
|
+
return None
|
|
88
|
+
payload, cached_at = row
|
|
89
|
+
if self._is_expired(cached_at):
|
|
90
|
+
with self._conn:
|
|
91
|
+
self._conn.execute(_DELETE_SQL, (source, group_id, artifact_id, version))
|
|
92
|
+
_logger.debug(
|
|
93
|
+
"Cache entry expired and deleted for %s/%s:%s@%s",
|
|
94
|
+
source,
|
|
95
|
+
group_id,
|
|
96
|
+
artifact_id,
|
|
97
|
+
version,
|
|
98
|
+
)
|
|
99
|
+
return None
|
|
100
|
+
_logger.debug(
|
|
101
|
+
"Cache hit for %s/%s:%s@%s", source, group_id, artifact_id, version
|
|
102
|
+
)
|
|
103
|
+
return payload
|
|
104
|
+
|
|
105
|
+
def put( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
106
|
+
self,
|
|
107
|
+
source: str,
|
|
108
|
+
group_id: str,
|
|
109
|
+
artifact_id: str,
|
|
110
|
+
version: str,
|
|
111
|
+
payload: str,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Insert or replace the cache entry for the given key.
|
|
115
|
+
|
|
116
|
+
*payload* must be a JSON string representing the raw API response.
|
|
117
|
+
|
|
118
|
+
:author: Ron Webb
|
|
119
|
+
:since: 1.0.0
|
|
120
|
+
"""
|
|
121
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
122
|
+
with self._conn:
|
|
123
|
+
self._conn.execute(
|
|
124
|
+
_UPSERT_SQL,
|
|
125
|
+
(source, group_id, artifact_id, version, payload, now),
|
|
126
|
+
)
|
|
127
|
+
_logger.debug(
|
|
128
|
+
"Cached payload for %s/%s:%s@%s", source, group_id, artifact_id, version
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Close the underlying SQLite connection.
|
|
134
|
+
|
|
135
|
+
Call this when the cache is no longer needed to release the database
|
|
136
|
+
file handle immediately. Callers that passed their own *connection* to
|
|
137
|
+
the constructor are responsible for not using the connection after calling
|
|
138
|
+
this method.
|
|
139
|
+
|
|
140
|
+
:author: Ron Webb
|
|
141
|
+
:since: 1.0.0
|
|
142
|
+
"""
|
|
143
|
+
self._conn.close()
|
|
144
|
+
|
|
145
|
+
def _is_expired(self, cached_at: str) -> bool:
|
|
146
|
+
"""
|
|
147
|
+
Return ``True`` when *cached_at* is older than the configured TTL.
|
|
148
|
+
|
|
149
|
+
:author: Ron Webb
|
|
150
|
+
:since: 1.0.0
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
entry_time = datetime.strptime(cached_at, "%Y-%m-%d %H:%M:%S")
|
|
154
|
+
except ValueError:
|
|
155
|
+
return True
|
|
156
|
+
return datetime.now() - entry_time > timedelta(days=self._ttl_days)
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli module.
|
|
3
|
+
|
|
4
|
+
Command-line interface entry point for the Java Dependency Analyzer.
|
|
5
|
+
|
|
6
|
+
:author: Ron Webb
|
|
7
|
+
:since: 1.0.0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from .cache.db import delete_database
|
|
15
|
+
from .cache.vulnerability_cache import VulnerabilityCache
|
|
16
|
+
from .models.dependency import Dependency
|
|
17
|
+
from .models.report import ScanResult
|
|
18
|
+
from .parsers.gradle_dep_tree_parser import GradleDepTreeParser
|
|
19
|
+
from .parsers.gradle_parser import GradleParser
|
|
20
|
+
from .parsers.maven_dep_tree_parser import MavenDepTreeParser
|
|
21
|
+
from .parsers.maven_parser import MavenParser
|
|
22
|
+
from .reporters.html_reporter import HtmlReporter
|
|
23
|
+
from .reporters.json_reporter import JsonReporter
|
|
24
|
+
from .resolvers.transitive import TransitiveResolver
|
|
25
|
+
from .scanners.ghsa_scanner import GhsaScanner
|
|
26
|
+
from .scanners.osv_scanner import OsvScanner
|
|
27
|
+
from .util.logger import setup_logger
|
|
28
|
+
|
|
29
|
+
__author__ = "Ron Webb"
|
|
30
|
+
__since__ = "1.0.0"
|
|
31
|
+
|
|
32
|
+
_logger = setup_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Shared CLI options applied to both subcommands
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
_COMMON_OPTIONS = [
|
|
39
|
+
click.option(
|
|
40
|
+
"--output-format",
|
|
41
|
+
"-f",
|
|
42
|
+
type=click.Choice(["json", "html", "all"], case_sensitive=False),
|
|
43
|
+
default="all",
|
|
44
|
+
show_default=True,
|
|
45
|
+
help="Output format for the vulnerability report.",
|
|
46
|
+
),
|
|
47
|
+
click.option(
|
|
48
|
+
"--output-dir",
|
|
49
|
+
"-o",
|
|
50
|
+
default="./reports",
|
|
51
|
+
show_default=True,
|
|
52
|
+
type=click.Path(file_okay=False),
|
|
53
|
+
help="Directory to write the report file(s) into.",
|
|
54
|
+
),
|
|
55
|
+
click.option(
|
|
56
|
+
"--no-transitive",
|
|
57
|
+
is_flag=True,
|
|
58
|
+
default=False,
|
|
59
|
+
help="Skip transitive dependency resolution (direct dependencies only).",
|
|
60
|
+
),
|
|
61
|
+
click.option(
|
|
62
|
+
"--verbose",
|
|
63
|
+
"-v",
|
|
64
|
+
is_flag=True,
|
|
65
|
+
default=False,
|
|
66
|
+
help="Enable verbose progress output.",
|
|
67
|
+
),
|
|
68
|
+
click.option(
|
|
69
|
+
"--rebuild-cache",
|
|
70
|
+
is_flag=True,
|
|
71
|
+
default=False,
|
|
72
|
+
help="Delete the vulnerability cache database before scanning.",
|
|
73
|
+
),
|
|
74
|
+
click.option(
|
|
75
|
+
"--cache-ttl",
|
|
76
|
+
default=7,
|
|
77
|
+
show_default=True,
|
|
78
|
+
type=int,
|
|
79
|
+
help="Cache TTL in days. Set to 0 to disable caching.",
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _common_options(func):
|
|
85
|
+
"""Apply all shared options to a Click command."""
|
|
86
|
+
for option in reversed(_COMMON_OPTIONS):
|
|
87
|
+
func = option(func)
|
|
88
|
+
return func
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Click group
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@click.group()
|
|
97
|
+
def main() -> None:
|
|
98
|
+
"""Java Dependency Analyzer -- inspect Java dependency trees for known vulnerabilities."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# gradle subcommand
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@main.command()
|
|
107
|
+
@click.argument(
|
|
108
|
+
"file",
|
|
109
|
+
required=False,
|
|
110
|
+
default=None,
|
|
111
|
+
type=click.Path(exists=True, dir_okay=False, readable=True),
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--dependencies",
|
|
115
|
+
"-d",
|
|
116
|
+
default=None,
|
|
117
|
+
type=click.Path(exists=True, dir_okay=False, readable=True),
|
|
118
|
+
help=(
|
|
119
|
+
"Path to a pre-resolved Gradle dependency tree text file "
|
|
120
|
+
"(output of ``gradle dependencies``). When supplied, transitive "
|
|
121
|
+
"resolution is skipped."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
@_common_options
|
|
125
|
+
def gradle( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
126
|
+
file: str | None,
|
|
127
|
+
dependencies: str | None,
|
|
128
|
+
output_format: str,
|
|
129
|
+
output_dir: str,
|
|
130
|
+
no_transitive: bool,
|
|
131
|
+
verbose: bool,
|
|
132
|
+
rebuild_cache: bool,
|
|
133
|
+
cache_ttl: int,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Analyse a Gradle build file (build.gradle or build.gradle.kts) for known
|
|
137
|
+
dependency vulnerabilities.
|
|
138
|
+
|
|
139
|
+
FILE is the path to a build.gradle or build.gradle.kts file. Alternatively,
|
|
140
|
+
supply a pre-resolved dependency tree via --dependencies to skip both parsing
|
|
141
|
+
and transitive resolution.
|
|
142
|
+
|
|
143
|
+
:author: Ron Webb
|
|
144
|
+
:since: 1.0.0
|
|
145
|
+
"""
|
|
146
|
+
if file is None and dependencies is None:
|
|
147
|
+
raise click.UsageError("Provide FILE or --dependencies (or both).")
|
|
148
|
+
|
|
149
|
+
if file is not None:
|
|
150
|
+
file_path = Path(file)
|
|
151
|
+
name = file_path.name
|
|
152
|
+
if not (name.endswith("build.gradle.kts") or name.endswith("build.gradle")):
|
|
153
|
+
raise click.UsageError(
|
|
154
|
+
f"Unsupported file: {name}. Expected build.gradle or build.gradle.kts."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
cache = _init_cache(rebuild_cache, cache_ttl, verbose)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
if dependencies is not None:
|
|
161
|
+
if verbose:
|
|
162
|
+
click.echo(f"Loading dependency tree from {dependencies}...")
|
|
163
|
+
parsed_deps = GradleDepTreeParser().parse(dependencies)
|
|
164
|
+
source = file if file is not None else dependencies
|
|
165
|
+
_run_analysis(
|
|
166
|
+
parsed_deps,
|
|
167
|
+
source_file=source,
|
|
168
|
+
output_format=output_format,
|
|
169
|
+
output_dir=output_dir,
|
|
170
|
+
no_transitive=True,
|
|
171
|
+
verbose=verbose,
|
|
172
|
+
cache=cache,
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
if verbose:
|
|
176
|
+
click.echo(f"Parsing {Path(file).name}...") # type: ignore[arg-type]
|
|
177
|
+
parsed_deps = GradleParser().parse(file) # type: ignore[arg-type]
|
|
178
|
+
_run_analysis(
|
|
179
|
+
parsed_deps,
|
|
180
|
+
source_file=file, # type: ignore[arg-type]
|
|
181
|
+
output_format=output_format,
|
|
182
|
+
output_dir=output_dir,
|
|
183
|
+
no_transitive=no_transitive,
|
|
184
|
+
verbose=verbose,
|
|
185
|
+
cache=cache,
|
|
186
|
+
)
|
|
187
|
+
finally:
|
|
188
|
+
if cache is not None:
|
|
189
|
+
cache.close()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# maven subcommand
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@main.command()
|
|
198
|
+
@click.argument(
|
|
199
|
+
"file",
|
|
200
|
+
required=False,
|
|
201
|
+
default=None,
|
|
202
|
+
type=click.Path(exists=True, dir_okay=False, readable=True),
|
|
203
|
+
)
|
|
204
|
+
@click.option(
|
|
205
|
+
"--dependencies",
|
|
206
|
+
"-d",
|
|
207
|
+
default=None,
|
|
208
|
+
type=click.Path(exists=True, dir_okay=False, readable=True),
|
|
209
|
+
help=(
|
|
210
|
+
"Path to a pre-resolved Maven dependency tree text file "
|
|
211
|
+
"(output of ``mvn dependency:tree``). When supplied, transitive "
|
|
212
|
+
"resolution is skipped."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
@_common_options
|
|
216
|
+
def maven( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
217
|
+
file: str | None,
|
|
218
|
+
dependencies: str | None,
|
|
219
|
+
output_format: str,
|
|
220
|
+
output_dir: str,
|
|
221
|
+
no_transitive: bool,
|
|
222
|
+
verbose: bool,
|
|
223
|
+
rebuild_cache: bool,
|
|
224
|
+
cache_ttl: int,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Analyse a Maven POM file (pom.xml) for known dependency vulnerabilities.
|
|
228
|
+
|
|
229
|
+
FILE is the path to a pom.xml file. Alternatively, supply a pre-resolved
|
|
230
|
+
dependency tree via --dependencies to skip both parsing and transitive
|
|
231
|
+
resolution.
|
|
232
|
+
|
|
233
|
+
:author: Ron Webb
|
|
234
|
+
:since: 1.0.0
|
|
235
|
+
"""
|
|
236
|
+
if file is None and dependencies is None:
|
|
237
|
+
raise click.UsageError("Provide FILE or --dependencies (or both).")
|
|
238
|
+
|
|
239
|
+
if file is not None:
|
|
240
|
+
file_path = Path(file)
|
|
241
|
+
if not file_path.name.endswith("pom.xml"):
|
|
242
|
+
raise click.UsageError(
|
|
243
|
+
f"Unsupported file: {file_path.name}. Expected pom.xml."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
cache = _init_cache(rebuild_cache, cache_ttl, verbose)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
if dependencies is not None:
|
|
250
|
+
if verbose:
|
|
251
|
+
click.echo(f"Loading dependency tree from {dependencies}...")
|
|
252
|
+
parsed_deps = MavenDepTreeParser().parse(dependencies)
|
|
253
|
+
source = file if file is not None else dependencies
|
|
254
|
+
_run_analysis(
|
|
255
|
+
parsed_deps,
|
|
256
|
+
source_file=source,
|
|
257
|
+
output_format=output_format,
|
|
258
|
+
output_dir=output_dir,
|
|
259
|
+
no_transitive=True,
|
|
260
|
+
verbose=verbose,
|
|
261
|
+
cache=cache,
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
if verbose:
|
|
265
|
+
click.echo(f"Parsing {Path(file).name}...") # type: ignore[arg-type]
|
|
266
|
+
parsed_deps = MavenParser().parse(file) # type: ignore[arg-type]
|
|
267
|
+
_run_analysis(
|
|
268
|
+
parsed_deps,
|
|
269
|
+
source_file=file, # type: ignore[arg-type]
|
|
270
|
+
output_format=output_format,
|
|
271
|
+
output_dir=output_dir,
|
|
272
|
+
no_transitive=no_transitive,
|
|
273
|
+
verbose=verbose,
|
|
274
|
+
cache=cache,
|
|
275
|
+
)
|
|
276
|
+
finally:
|
|
277
|
+
if cache is not None:
|
|
278
|
+
cache.close()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Private helpers
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _init_cache(
|
|
287
|
+
rebuild_cache: bool, cache_ttl: int, verbose: bool
|
|
288
|
+
) -> VulnerabilityCache | None:
|
|
289
|
+
"""
|
|
290
|
+
Optionally clear and then create the vulnerability cache.
|
|
291
|
+
|
|
292
|
+
:author: Ron Webb
|
|
293
|
+
:since: 1.0.0
|
|
294
|
+
"""
|
|
295
|
+
if rebuild_cache:
|
|
296
|
+
delete_database()
|
|
297
|
+
if verbose:
|
|
298
|
+
click.echo("Vulnerability cache cleared.")
|
|
299
|
+
|
|
300
|
+
return VulnerabilityCache(ttl_days=cache_ttl) if cache_ttl > 0 else None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _run_analysis( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
304
|
+
dependencies: list[Dependency],
|
|
305
|
+
source_file: str,
|
|
306
|
+
output_format: str,
|
|
307
|
+
output_dir: str,
|
|
308
|
+
no_transitive: bool,
|
|
309
|
+
verbose: bool,
|
|
310
|
+
cache: VulnerabilityCache | None,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""
|
|
313
|
+
Resolve transitive dependencies (unless skipped), scan for vulnerabilities,
|
|
314
|
+
and write the requested reports.
|
|
315
|
+
|
|
316
|
+
:author: Ron Webb
|
|
317
|
+
:since: 1.0.0
|
|
318
|
+
"""
|
|
319
|
+
if not dependencies:
|
|
320
|
+
click.echo("No runtime dependencies found.", err=True)
|
|
321
|
+
|
|
322
|
+
if verbose:
|
|
323
|
+
click.echo(f"Found {len(dependencies)} direct dependencies.")
|
|
324
|
+
|
|
325
|
+
if not no_transitive:
|
|
326
|
+
if verbose:
|
|
327
|
+
click.echo("Resolving transitive dependencies from Maven Central...")
|
|
328
|
+
TransitiveResolver().resolve_all(dependencies)
|
|
329
|
+
|
|
330
|
+
if verbose:
|
|
331
|
+
click.echo("Scanning for vulnerabilities...")
|
|
332
|
+
|
|
333
|
+
osv = OsvScanner(cache=cache)
|
|
334
|
+
ghsa = GhsaScanner(cache=cache)
|
|
335
|
+
_scan_all(dependencies, osv, ghsa, verbose)
|
|
336
|
+
|
|
337
|
+
result = ScanResult(source_file=source_file, dependencies=dependencies)
|
|
338
|
+
|
|
339
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
340
|
+
_write_reports(result, Path(output_dir), output_format, verbose)
|
|
341
|
+
|
|
342
|
+
click.echo(
|
|
343
|
+
f"\nScan complete. "
|
|
344
|
+
f"{result.total_dependencies} dependencies, "
|
|
345
|
+
f"{result.total_vulnerabilities} vulnerabilities found."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _scan_all(
|
|
350
|
+
dependencies: list[Dependency],
|
|
351
|
+
osv: OsvScanner,
|
|
352
|
+
ghsa: GhsaScanner,
|
|
353
|
+
verbose: bool,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""
|
|
356
|
+
Recursively scan all dependencies (direct + transitive) for vulnerabilities.
|
|
357
|
+
|
|
358
|
+
Uses the GitHub Advisory Database (GHSA) as the primary source. When GHSA
|
|
359
|
+
returns no results -- either because the API failed or no advisories were
|
|
360
|
+
found -- the OSV.dev scanner is used as a fallback.
|
|
361
|
+
|
|
362
|
+
:author: Ron Webb
|
|
363
|
+
:since: 1.0.0
|
|
364
|
+
"""
|
|
365
|
+
for dep in dependencies:
|
|
366
|
+
if verbose:
|
|
367
|
+
click.echo(f" Scanning {dep.coordinates}...")
|
|
368
|
+
ghsa_vulns = ghsa.scan(dep)
|
|
369
|
+
dep.vulnerabilities = ghsa_vulns if ghsa_vulns else osv.scan(dep)
|
|
370
|
+
_scan_all(dep.transitive_dependencies, osv, ghsa, verbose)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _write_reports(
|
|
374
|
+
result: ScanResult, output_dir: Path, output_format: str, verbose: bool
|
|
375
|
+
) -> None:
|
|
376
|
+
"""
|
|
377
|
+
Write one or both report formats based on the --output-format flag.
|
|
378
|
+
|
|
379
|
+
:author: Ron Webb
|
|
380
|
+
:since: 1.0.0
|
|
381
|
+
"""
|
|
382
|
+
stem = Path(result.source_file).stem
|
|
383
|
+
|
|
384
|
+
if output_format in ("json", "all"):
|
|
385
|
+
json_path = output_dir / f"{stem}-report.json"
|
|
386
|
+
JsonReporter().report(result, str(json_path))
|
|
387
|
+
if verbose:
|
|
388
|
+
click.echo(f"JSON report: {json_path}")
|
|
389
|
+
|
|
390
|
+
if output_format in ("html", "all"):
|
|
391
|
+
html_path = output_dir / f"{stem}-report.html"
|
|
392
|
+
HtmlReporter().report(result, str(html_path))
|
|
393
|
+
if verbose:
|
|
394
|
+
click.echo(f"HTML report: {html_path}")
|