ecdat 0.2.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.
- ecdat/__init__.py +10 -0
- ecdat/__main__.py +5 -0
- ecdat/cli/__init__.py +203 -0
- ecdat/cli/commands/__init__.py +0 -0
- ecdat/cli/commands/about.py +130 -0
- ecdat/cli/commands/demo.py +116 -0
- ecdat/cli/commands/doctor.py +296 -0
- ecdat/cli/commands/help_cmd.py +205 -0
- ecdat/cli/commands/scan.py +228 -0
- ecdat/cli/commands/version_cmd.py +48 -0
- ecdat/cli/parser.py +87 -0
- ecdat/demo_project/auth/login.py +75 -0
- ecdat/demo_project/certs/cert_verify.go +81 -0
- ecdat/demo_project/keyexchange/channel.go +48 -0
- ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
- ecdat/demo_project/payments/payment.py +64 -0
- ecdat/demo_project/quantum/pqc_utils.py +67 -0
- ecdat/demo_project/quantum/slh_signer.py +40 -0
- ecdat/demo_project/tokens/signing.js +54 -0
- ecdat/py.typed +0 -0
- ecdat/services/__init__.py +1 -0
- ecdat/services/crashlog.py +109 -0
- ecdat/services/demo.py +85 -0
- ecdat/services/paths.py +52 -0
- ecdat/services/scanner.py +248 -0
- ecdat/services/viewmodel.py +326 -0
- ecdat/ui/__init__.py +1 -0
- ecdat/ui/art3d.py +136 -0
- ecdat/ui/art_static.py +65 -0
- ecdat/ui/art_text.py +81 -0
- ecdat/ui/banner.py +148 -0
- ecdat/ui/console.py +119 -0
- ecdat/ui/motion.py +64 -0
- ecdat/ui/render.py +486 -0
- ecdat/ui/theme.py +173 -0
- ecdat-0.2.0.dist-info/METADATA +142 -0
- ecdat-0.2.0.dist-info/RECORD +51 -0
- ecdat-0.2.0.dist-info/WHEEL +5 -0
- ecdat-0.2.0.dist-info/entry_points.txt +2 -0
- ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
- ecdat-0.2.0.dist-info/top_level.txt +2 -0
- ecdat_core/__init__.py +6 -0
- ecdat_core/cbom_export.py +287 -0
- ecdat_core/cli.py +202 -0
- ecdat_core/detector.py +273 -0
- ecdat_core/ingestion.py +581 -0
- ecdat_core/models.py +145 -0
- ecdat_core/recommender.py +74 -0
- ecdat_core/risk_engine.py +264 -0
- ecdat_core/signature_loader.py +204 -0
- ecdat_core/signatures.json +692 -0
ecdat_core/ingestion.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""File-system and Git ingestion for the ECDAT scanner.
|
|
2
|
+
|
|
3
|
+
Walks local directories and clones Git repos to collect source files for
|
|
4
|
+
cryptographic analysis. Also extracts manifest-level dependency metadata
|
|
5
|
+
from common package managers.
|
|
6
|
+
|
|
7
|
+
Security model (see AGENTS.md "Security Conventions"):
|
|
8
|
+
|
|
9
|
+
- Git URLs are validated **before** any ``git clone`` is attempted: only
|
|
10
|
+
``https://`` is accepted, every address the hostname resolves to must be
|
|
11
|
+
public (private/loopback/link-local/reserved/multicast targets are
|
|
12
|
+
rejected — SSRF protection), an optional ``GIT_URL_ALLOWED_HOSTS``
|
|
13
|
+
allowlist further restricts hosts, and a post-clone
|
|
14
|
+
``GIT_URL_MAX_SIZE_MB`` ceiling aborts oversized repos cleanly.
|
|
15
|
+
- Local paths must resolve (symlinks followed via ``realpath``) inside
|
|
16
|
+
``SCAN_WORKSPACE_ROOT`` (default: the bundled ``ecdat_core/tests/fixtures``
|
|
17
|
+
directory) — arbitrary path reads are rejected via :func:`validate_local_path`.
|
|
18
|
+
|
|
19
|
+
Public API:
|
|
20
|
+
- :func:`ingest_local_directory` – walk a directory tree, yield source files.
|
|
21
|
+
- :func:`validate_local_path` – enforce the local-path scan sandbox.
|
|
22
|
+
- :func:`ingest_git_url` – shallow-clone a Git repository, return local path.
|
|
23
|
+
- :func:`validate_git_url` – enforce the git-URL SSRF protections.
|
|
24
|
+
- :func:`ingest_manifest_dependencies` – extract dependency info from manifests.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import ipaddress
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import socket
|
|
34
|
+
import subprocess
|
|
35
|
+
import tempfile
|
|
36
|
+
from collections.abc import Iterator
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from urllib.parse import urlparse
|
|
39
|
+
|
|
40
|
+
from ecdat_core.detector import detect_language
|
|
41
|
+
|
|
42
|
+
# Directories to skip during directory traversal.
|
|
43
|
+
_SKIP_DIRS: frozenset[str] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
".git",
|
|
46
|
+
"node_modules",
|
|
47
|
+
"venv",
|
|
48
|
+
".venv",
|
|
49
|
+
"__pycache__",
|
|
50
|
+
"dist",
|
|
51
|
+
"build",
|
|
52
|
+
"target",
|
|
53
|
+
".next",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Maximum file size in bytes — files larger than this are silently skipped.
|
|
58
|
+
_MAX_FILE_SIZE = 2 * 1024 * 1024 # 2 MB
|
|
59
|
+
|
|
60
|
+
# How many directory levels deep to scan for manifest files.
|
|
61
|
+
_MANIFEST_DEPTH = 3
|
|
62
|
+
|
|
63
|
+
# Timeout in seconds for git clone operations.
|
|
64
|
+
_GIT_CLONE_TIMEOUT = 60
|
|
65
|
+
|
|
66
|
+
# Security policy configuration. All read from the environment at call time
|
|
67
|
+
# so tests can override per-test; see AGENTS.md "Security Conventions".
|
|
68
|
+
_ENV_WORKSPACE_ROOT = "SCAN_WORKSPACE_ROOT"
|
|
69
|
+
_ENV_ALLOWED_HOSTS = "GIT_URL_ALLOWED_HOSTS"
|
|
70
|
+
_ENV_MAX_SIZE_MB = "GIT_URL_MAX_SIZE_MB"
|
|
71
|
+
|
|
72
|
+
# Default sandbox root: the bundled demo/showcase fixtures live here, so the
|
|
73
|
+
# existing fixture-based scans keep working with zero configuration.
|
|
74
|
+
_DEFAULT_WORKSPACE_ROOT = Path(__file__).resolve().parent / "tests" / "fixtures"
|
|
75
|
+
|
|
76
|
+
# Default post-clone repo size ceiling (MB) when GIT_URL_MAX_SIZE_MB is unset
|
|
77
|
+
# or invalid — a few hundred MB.
|
|
78
|
+
_DEFAULT_MAX_REPO_SIZE_MB = 200
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Local-path scan sandbox
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_workspace_root() -> Path:
|
|
87
|
+
"""Return the realpath of ``SCAN_WORKSPACE_ROOT`` (default: bundled fixtures).
|
|
88
|
+
|
|
89
|
+
Reads ``SCAN_WORKSPACE_ROOT`` from the environment at call time; when unset,
|
|
90
|
+
falls back to :data:`_DEFAULT_WORKSPACE_ROOT` (the directory holding the
|
|
91
|
+
``demo_repo`` / ``showcase_repo`` fixtures), so existing scans keep working
|
|
92
|
+
with zero configuration.
|
|
93
|
+
"""
|
|
94
|
+
configured = os.getenv(_ENV_WORKSPACE_ROOT)
|
|
95
|
+
if configured:
|
|
96
|
+
return Path(configured).resolve()
|
|
97
|
+
return _DEFAULT_WORKSPACE_ROOT.resolve()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_within(root: Path, candidate: Path) -> bool:
|
|
101
|
+
"""Return whether *candidate* equals *root* or is nested inside it."""
|
|
102
|
+
return candidate == root or root in candidate.parents
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_local_path(path: str) -> str:
|
|
106
|
+
"""Validate that *path* resolves inside ``SCAN_WORKSPACE_ROOT``.
|
|
107
|
+
|
|
108
|
+
Resolves symlinks via :func:`os.path.realpath` and rejects any path whose
|
|
109
|
+
resolved location is not inside the configured workspace root. This closes
|
|
110
|
+
the arbitrary-path-read hole (e.g. submitting ``/etc`` or ``/app/backend``
|
|
111
|
+
as a scan target) while keeping the bundled fixture scans working.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
path: The local path a scan request asks to read.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The resolved absolute path when *path* is inside the workspace.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: If *path* resolves outside ``SCAN_WORKSPACE_ROOT``.
|
|
121
|
+
"""
|
|
122
|
+
resolved = Path(path).resolve()
|
|
123
|
+
root = _resolve_workspace_root()
|
|
124
|
+
if not _is_within(root, resolved):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Path {path!r} is outside SCAN_WORKSPACE_ROOT ({root}). "
|
|
127
|
+
"Only paths inside the configured scan workspace may be scanned."
|
|
128
|
+
)
|
|
129
|
+
return str(resolved)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Git URL SSRF protection
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _allowed_git_hosts() -> frozenset[str]:
|
|
138
|
+
"""Parse the ``GIT_URL_ALLOWED_HOSTS`` allowlist from the environment.
|
|
139
|
+
|
|
140
|
+
Returns an empty frozenset when the variable is unset (no restriction).
|
|
141
|
+
Entries are comma-separated, trimmed, lowercased, and empties dropped.
|
|
142
|
+
"""
|
|
143
|
+
raw = os.getenv(_ENV_ALLOWED_HOSTS, "")
|
|
144
|
+
return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_git_url(git_url: str) -> None:
|
|
148
|
+
"""Reject git URLs that could target non-public (internal) network hosts.
|
|
149
|
+
|
|
150
|
+
Enforced before any ``git clone`` is attempted:
|
|
151
|
+
|
|
152
|
+
1. Scheme must be ``https://`` — ``file://``, ``git://``, ``ssh://``,
|
|
153
|
+
plain ``http://`` and scheme-less strings are rejected.
|
|
154
|
+
2. When ``GIT_URL_ALLOWED_HOSTS`` is set (comma-separated hostnames), the
|
|
155
|
+
URL's hostname must be listed. Unset = no allowlist restriction.
|
|
156
|
+
3. Every address the hostname resolves to via :func:`socket.getaddrinfo`
|
|
157
|
+
must be a public address — any resolved address flagged by the
|
|
158
|
+
:mod:`ipaddress` module as private, loopback, link-local, reserved, or
|
|
159
|
+
multicast rejects the URL. This blocks internal networks, loopback,
|
|
160
|
+
and cloud metadata endpoints such as ``169.254.169.254``.
|
|
161
|
+
|
|
162
|
+
.. note::
|
|
163
|
+
This is a resolve-then-connect check and does not defend against
|
|
164
|
+
DNS-rebinding (the hostname could resolve differently between this
|
|
165
|
+
check and the actual clone). That TOCTOU window is a known,
|
|
166
|
+
accepted residual risk at this stage.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
git_url: The repository URL to validate.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ValueError: With a clear message describing the specific rejection.
|
|
173
|
+
"""
|
|
174
|
+
parsed = urlparse(git_url)
|
|
175
|
+
if parsed.scheme.lower() != "https":
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"Only https:// git URLs are allowed; got scheme "
|
|
178
|
+
f"{parsed.scheme or '(none)'!r} for {git_url!r}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
hostname = parsed.hostname
|
|
182
|
+
if not hostname:
|
|
183
|
+
raise ValueError(f"Git URL {git_url!r} has no hostname")
|
|
184
|
+
hostname = hostname.lower()
|
|
185
|
+
|
|
186
|
+
allowlist = _allowed_git_hosts()
|
|
187
|
+
if allowlist and hostname not in allowlist:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Host {hostname!r} is not in the GIT_URL_ALLOWED_HOSTS allowlist"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
resolved = socket.getaddrinfo(hostname, 443, proto=socket.IPPROTO_TCP)
|
|
194
|
+
except socket.gaierror as exc:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Could not resolve host {hostname!r} for git URL {git_url!r}: {exc}"
|
|
197
|
+
) from exc
|
|
198
|
+
|
|
199
|
+
for addr in resolved:
|
|
200
|
+
ip_str = addr[4][0].split("%", 1)[0] # strip IPv6 scope ids
|
|
201
|
+
ip = ipaddress.ip_address(ip_str)
|
|
202
|
+
# Check the embedded IPv4 for IPv4-mapped IPv6 (::ffff:a.b.c.d).
|
|
203
|
+
probe = ip
|
|
204
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
205
|
+
probe = ip.ipv4_mapped
|
|
206
|
+
if (
|
|
207
|
+
probe.is_private
|
|
208
|
+
or probe.is_loopback
|
|
209
|
+
or probe.is_link_local
|
|
210
|
+
or probe.is_reserved
|
|
211
|
+
or probe.is_multicast
|
|
212
|
+
):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Git URL host {hostname!r} resolves to non-public address "
|
|
215
|
+
f"{ip_str}; refusing to clone an internal/private target"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _dir_size(root: Path) -> int:
|
|
220
|
+
"""Return the total size in bytes of every file under *root*."""
|
|
221
|
+
total = 0
|
|
222
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
223
|
+
for name in filenames:
|
|
224
|
+
try:
|
|
225
|
+
total += os.path.getsize(os.path.join(dirpath, name))
|
|
226
|
+
except OSError:
|
|
227
|
+
continue
|
|
228
|
+
return total
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _max_repo_size_bytes() -> int:
|
|
232
|
+
"""Return the ``GIT_URL_MAX_SIZE_MB`` ceiling in bytes (default 200 MB)."""
|
|
233
|
+
try:
|
|
234
|
+
mb = int(os.getenv(_ENV_MAX_SIZE_MB, ""))
|
|
235
|
+
except ValueError:
|
|
236
|
+
mb = _DEFAULT_MAX_REPO_SIZE_MB
|
|
237
|
+
if mb <= 0:
|
|
238
|
+
mb = _DEFAULT_MAX_REPO_SIZE_MB
|
|
239
|
+
return mb * 1024 * 1024
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def ingest_local_directory(
|
|
243
|
+
path: str, *, sandboxed: bool = True
|
|
244
|
+
) -> Iterator[tuple[str, str, str]]:
|
|
245
|
+
"""Walk *path* and yield source files suitable for cryptographic scanning.
|
|
246
|
+
|
|
247
|
+
Traverses the directory tree rooted at *path*, skipping well-known
|
|
248
|
+
non-source directories (``.git``, ``node_modules``, ``venv``, etc.)
|
|
249
|
+
and any file larger than 2 MB. For each remaining file whose extension
|
|
250
|
+
is recognised by :func:`~ecdat_core.detector.detect_language`, the file
|
|
251
|
+
content is read and yielded together with its path and detected language.
|
|
252
|
+
|
|
253
|
+
Files that cannot be decoded as UTF-8 are silently skipped so that a
|
|
254
|
+
single binary file does not abort the entire scan.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
path: Root directory to walk.
|
|
258
|
+
sandboxed: When ``True`` (default) *path* must resolve inside
|
|
259
|
+
``SCAN_WORKSPACE_ROOT`` (see :func:`validate_local_path`). Pass
|
|
260
|
+
``False`` only for scanner-created temporary clone directories
|
|
261
|
+
(see :func:`~ecdat_core.cli.run_scan`) — never for user-supplied
|
|
262
|
+
paths.
|
|
263
|
+
|
|
264
|
+
Yields:
|
|
265
|
+
Tuples of ``(file_path, content, language)`` for each successfully
|
|
266
|
+
read source file.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
FileNotFoundError: If *path* does not exist or is not a directory.
|
|
270
|
+
ValueError: If ``sandboxed`` and *path* resolves outside
|
|
271
|
+
``SCAN_WORKSPACE_ROOT``.
|
|
272
|
+
"""
|
|
273
|
+
root = Path(path)
|
|
274
|
+
if not root.is_dir():
|
|
275
|
+
raise FileNotFoundError(f"Not a directory: {path}")
|
|
276
|
+
if sandboxed:
|
|
277
|
+
validate_local_path(path)
|
|
278
|
+
|
|
279
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
280
|
+
# Prune skipped directories **in-place** so os.walk does not descend.
|
|
281
|
+
dirnames[:] = [
|
|
282
|
+
d for d in dirnames if d not in _SKIP_DIRS
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
for filename in filenames:
|
|
286
|
+
file_path = os.path.join(dirpath, filename)
|
|
287
|
+
|
|
288
|
+
# Skip oversized files.
|
|
289
|
+
try:
|
|
290
|
+
if os.path.getsize(file_path) > _MAX_FILE_SIZE:
|
|
291
|
+
continue
|
|
292
|
+
except OSError:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
language = detect_language(file_path)
|
|
296
|
+
if language is None:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
content = Path(file_path).read_text(encoding="utf-8")
|
|
301
|
+
except (UnicodeDecodeError, OSError):
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
yield (file_path, content, language)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def ingest_git_url(git_url: str, workdir: str | None = None) -> str:
|
|
308
|
+
"""Shallow-clone a Git repository and return the local path.
|
|
309
|
+
|
|
310
|
+
Runs ``git clone --depth 1 <url> <tmpdir>``. If *workdir* is not
|
|
311
|
+
provided a temporary directory is created via :func:`tempfile.mkdtemp`.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
git_url: The repository URL to clone.
|
|
315
|
+
workdir: Optional existing directory to clone into. When ``None`` a
|
|
316
|
+
temporary directory is created and its path is returned.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
The local filesystem path the repository was cloned into.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
ValueError: If *git_url* fails :func:`validate_git_url` (non-https
|
|
323
|
+
scheme, non-public resolved address, or not on the
|
|
324
|
+
``GIT_URL_ALLOWED_HOSTS`` allowlist).
|
|
325
|
+
RuntimeError: If the ``git clone`` command fails. The exception
|
|
326
|
+
message contains the stderr output from Git. Also raised when
|
|
327
|
+
the cloned repository exceeds the ``GIT_URL_MAX_SIZE_MB`` size
|
|
328
|
+
ceiling (the temporary clone directory is removed first).
|
|
329
|
+
subprocess.TimeoutExpired: If the clone exceeds 60 seconds.
|
|
330
|
+
"""
|
|
331
|
+
validate_git_url(git_url)
|
|
332
|
+
|
|
333
|
+
if workdir is not None:
|
|
334
|
+
target_dir = workdir
|
|
335
|
+
else:
|
|
336
|
+
target_dir = tempfile.mkdtemp(prefix="ecdat_clone_")
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
result = subprocess.run(
|
|
340
|
+
["git", "clone", "--depth", "1", git_url, target_dir],
|
|
341
|
+
capture_output=True,
|
|
342
|
+
text=True,
|
|
343
|
+
timeout=_GIT_CLONE_TIMEOUT,
|
|
344
|
+
)
|
|
345
|
+
except subprocess.TimeoutExpired as exc:
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
f"Git clone timed out after {_GIT_CLONE_TIMEOUT}s for {git_url}"
|
|
348
|
+
) from exc
|
|
349
|
+
|
|
350
|
+
if result.returncode != 0:
|
|
351
|
+
# Extract meaningful error lines from git stderr — strip progress
|
|
352
|
+
# output like "Cloning into '...'..." that adds noise for end users.
|
|
353
|
+
stderr_lines = result.stderr.strip().splitlines()
|
|
354
|
+
error_lines = [line for line in stderr_lines if line.startswith("fatal:")]
|
|
355
|
+
error_detail = (
|
|
356
|
+
"; ".join(error_lines) if error_lines else result.stderr.strip()
|
|
357
|
+
)
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
f"Could not clone repository: {error_detail}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Enforce the post-clone size ceiling so an oversized repository cannot
|
|
363
|
+
# consume resources or hang the scan.
|
|
364
|
+
size = _dir_size(Path(target_dir))
|
|
365
|
+
limit = _max_repo_size_bytes()
|
|
366
|
+
if size > limit:
|
|
367
|
+
if workdir is None:
|
|
368
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
369
|
+
raise RuntimeError(
|
|
370
|
+
f"Repository {git_url!r} is {size:,} bytes ({size // (1024 * 1024)} MB), "
|
|
371
|
+
f"exceeding the GIT_URL_MAX_SIZE_MB ceiling of "
|
|
372
|
+
f"{limit // (1024 * 1024)} MB; aborting scan"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return target_dir
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Manifest dependency extraction
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
# Regex patterns per manifest format. Each captures (name, version).
|
|
383
|
+
# Accepts ==, >=, <=, ~=, != and bare version specs after the name.
|
|
384
|
+
_REQ_TXT_RE = re.compile(
|
|
385
|
+
r"^\s*(?P<name>[A-Za-z0-9_.-]+)"
|
|
386
|
+
r"\s*(?:[<>=~!]+(?P<version>[^\s;#]+))?\s*(?:;.*)?\s*$"
|
|
387
|
+
)
|
|
388
|
+
_PKG_JSON_VER_RE = re.compile(r'"version"\s*:\s*"([^"]+)"')
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def ingest_manifest_dependencies(path: str) -> list[dict]:
|
|
392
|
+
"""Scan *path* for common manifest files and extract dependency info.
|
|
393
|
+
|
|
394
|
+
Searches up to :data:`_MANIFEST_DEPTH` levels deep for
|
|
395
|
+
``requirements.txt``, ``package.json``, ``pom.xml``, and ``go.mod``
|
|
396
|
+
files. For each found manifest a flat list of dependency records is
|
|
397
|
+
produced using lightweight regex extraction — full dependency-graph
|
|
398
|
+
resolution is intentionally out of scope.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
path: Root directory to scan for manifest files.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
A list of dicts, each with keys ``name``, ``version`` (or
|
|
405
|
+
``"unknown"``), and ``manifest_file`` (absolute path).
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
FileNotFoundError: If *path* does not exist or is not a directory.
|
|
409
|
+
ValueError: If *path* resolves outside ``SCAN_WORKSPACE_ROOT``.
|
|
410
|
+
"""
|
|
411
|
+
root = Path(path)
|
|
412
|
+
if not root.is_dir():
|
|
413
|
+
raise FileNotFoundError(f"Not a directory: {path}")
|
|
414
|
+
validate_local_path(path)
|
|
415
|
+
|
|
416
|
+
deps: list[dict] = []
|
|
417
|
+
|
|
418
|
+
for manifest_path in _find_manifests(root):
|
|
419
|
+
deps.extend(_extract_deps(manifest_path))
|
|
420
|
+
|
|
421
|
+
return deps
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---- private helpers ------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _find_manifests(root: Path) -> list[Path]:
|
|
428
|
+
"""Return manifest file paths found within *root* up to ``_MANIFEST_DEPTH``."""
|
|
429
|
+
manifests: list[Path] = []
|
|
430
|
+
manifest_names = {"requirements.txt", "package.json", "pom.xml", "go.mod"}
|
|
431
|
+
|
|
432
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
433
|
+
# Don't descend into well-known non-source dirs (node_modules, etc.).
|
|
434
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
435
|
+
|
|
436
|
+
# Calculate depth relative to root.
|
|
437
|
+
depth = len(Path(dirpath).relative_to(root).parts)
|
|
438
|
+
if depth >= _MANIFEST_DEPTH:
|
|
439
|
+
dirnames.clear() # stop descending
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
for fname in filenames:
|
|
443
|
+
if fname in manifest_names:
|
|
444
|
+
manifests.append(Path(dirpath) / fname)
|
|
445
|
+
|
|
446
|
+
return manifests
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _extract_deps(manifest_path: Path) -> list[dict]:
|
|
450
|
+
"""Extract dependency records from a single manifest file."""
|
|
451
|
+
name = manifest_path.name
|
|
452
|
+
extractors = {
|
|
453
|
+
"requirements.txt": _extract_requirements_txt,
|
|
454
|
+
"package.json": _extract_package_json,
|
|
455
|
+
"pom.xml": _extract_pom_xml,
|
|
456
|
+
"go.mod": _extract_go_mod,
|
|
457
|
+
}
|
|
458
|
+
extractor = extractors.get(name)
|
|
459
|
+
if extractor is None:
|
|
460
|
+
return []
|
|
461
|
+
return extractor(manifest_path)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _extract_requirements_txt(manifest: Path) -> list[dict]:
|
|
465
|
+
"""Parse ``requirements.txt`` for ``name==version`` pairs."""
|
|
466
|
+
deps: list[dict] = []
|
|
467
|
+
try:
|
|
468
|
+
text = manifest.read_text(encoding="utf-8")
|
|
469
|
+
except OSError:
|
|
470
|
+
return deps
|
|
471
|
+
|
|
472
|
+
for line in text.splitlines():
|
|
473
|
+
m = _REQ_TXT_RE.match(line.strip())
|
|
474
|
+
if not m:
|
|
475
|
+
continue
|
|
476
|
+
deps.append(
|
|
477
|
+
{
|
|
478
|
+
"name": m.group("name"),
|
|
479
|
+
"version": m.group("version") or "unknown",
|
|
480
|
+
"manifest_file": str(manifest),
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
return deps
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _extract_package_json(manifest: Path) -> list[dict]:
|
|
487
|
+
"""Extract ``dependencies`` and ``devDependencies`` from ``package.json``."""
|
|
488
|
+
import json
|
|
489
|
+
|
|
490
|
+
deps: list[dict] = []
|
|
491
|
+
try:
|
|
492
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
493
|
+
except (OSError, json.JSONDecodeError):
|
|
494
|
+
return deps
|
|
495
|
+
|
|
496
|
+
for dep_map_key in ("dependencies", "devDependencies"):
|
|
497
|
+
dep_map = data.get(dep_map_key, {})
|
|
498
|
+
if not isinstance(dep_map, dict):
|
|
499
|
+
continue
|
|
500
|
+
for name, version_spec in dep_map.items():
|
|
501
|
+
deps.append(
|
|
502
|
+
{
|
|
503
|
+
"name": name,
|
|
504
|
+
"version": str(version_spec) if version_spec else "unknown",
|
|
505
|
+
"manifest_file": str(manifest),
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
return deps
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _extract_pom_xml(manifest: Path) -> list[dict]:
|
|
512
|
+
"""Extract ``<dependency>`` entries from a Maven ``pom.xml``."""
|
|
513
|
+
deps: list[dict] = []
|
|
514
|
+
try:
|
|
515
|
+
text = manifest.read_text(encoding="utf-8")
|
|
516
|
+
except OSError:
|
|
517
|
+
return deps
|
|
518
|
+
|
|
519
|
+
# Lightweight regex: find <dependency> blocks and pull groupId/artifactId/version.
|
|
520
|
+
dep_blocks = re.findall(
|
|
521
|
+
r"<dependency>(.*?)</dependency>", text, re.DOTALL
|
|
522
|
+
)
|
|
523
|
+
for block in dep_blocks:
|
|
524
|
+
artifact_match = re.search(r"<artifactId>\s*(.+?)\s*</artifactId>", block)
|
|
525
|
+
version_match = re.search(r"<version>\s*(.+?)\s*</version>", block)
|
|
526
|
+
if artifact_match:
|
|
527
|
+
deps.append(
|
|
528
|
+
{
|
|
529
|
+
"name": artifact_match.group(1),
|
|
530
|
+
"version": version_match.group(1) if version_match else "unknown",
|
|
531
|
+
"manifest_file": str(manifest),
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
return deps
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _extract_go_mod(manifest: Path) -> list[dict]:
|
|
538
|
+
"""Extract ``require`` entries from a Go ``go.mod`` file."""
|
|
539
|
+
deps: list[dict] = []
|
|
540
|
+
try:
|
|
541
|
+
text = manifest.read_text(encoding="utf-8")
|
|
542
|
+
except OSError:
|
|
543
|
+
return deps
|
|
544
|
+
|
|
545
|
+
# Match lines like: github.com/foo/bar v1.2.3
|
|
546
|
+
# Inside or outside a require block — grab all module-version pairs.
|
|
547
|
+
in_require_block = False
|
|
548
|
+
for line in text.splitlines():
|
|
549
|
+
stripped = line.strip()
|
|
550
|
+
if stripped.startswith("require ("):
|
|
551
|
+
in_require_block = True
|
|
552
|
+
continue
|
|
553
|
+
if in_require_block and stripped == ")":
|
|
554
|
+
in_require_block = False
|
|
555
|
+
continue
|
|
556
|
+
if stripped.startswith("require ") and not in_require_block:
|
|
557
|
+
# Single-line require: require github.com/foo/bar v1.2.3
|
|
558
|
+
parts = stripped.split()
|
|
559
|
+
if len(parts) == 3:
|
|
560
|
+
deps.append(
|
|
561
|
+
{
|
|
562
|
+
"name": parts[1],
|
|
563
|
+
"version": parts[2],
|
|
564
|
+
"manifest_file": str(manifest),
|
|
565
|
+
}
|
|
566
|
+
)
|
|
567
|
+
continue
|
|
568
|
+
if in_require_block:
|
|
569
|
+
parts = stripped.split()
|
|
570
|
+
if len(parts) >= 2:
|
|
571
|
+
# Skip // indirect comments
|
|
572
|
+
name = parts[0]
|
|
573
|
+
version = parts[1]
|
|
574
|
+
deps.append(
|
|
575
|
+
{
|
|
576
|
+
"name": name,
|
|
577
|
+
"version": version,
|
|
578
|
+
"manifest_file": str(manifest),
|
|
579
|
+
}
|
|
580
|
+
)
|
|
581
|
+
return deps
|