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.
Files changed (51) hide show
  1. ecdat/__init__.py +10 -0
  2. ecdat/__main__.py +5 -0
  3. ecdat/cli/__init__.py +203 -0
  4. ecdat/cli/commands/__init__.py +0 -0
  5. ecdat/cli/commands/about.py +130 -0
  6. ecdat/cli/commands/demo.py +116 -0
  7. ecdat/cli/commands/doctor.py +296 -0
  8. ecdat/cli/commands/help_cmd.py +205 -0
  9. ecdat/cli/commands/scan.py +228 -0
  10. ecdat/cli/commands/version_cmd.py +48 -0
  11. ecdat/cli/parser.py +87 -0
  12. ecdat/demo_project/auth/login.py +75 -0
  13. ecdat/demo_project/certs/cert_verify.go +81 -0
  14. ecdat/demo_project/keyexchange/channel.go +48 -0
  15. ecdat/demo_project/legacy/LegacyCrypto.java +78 -0
  16. ecdat/demo_project/payments/payment.py +64 -0
  17. ecdat/demo_project/quantum/pqc_utils.py +67 -0
  18. ecdat/demo_project/quantum/slh_signer.py +40 -0
  19. ecdat/demo_project/tokens/signing.js +54 -0
  20. ecdat/py.typed +0 -0
  21. ecdat/services/__init__.py +1 -0
  22. ecdat/services/crashlog.py +109 -0
  23. ecdat/services/demo.py +85 -0
  24. ecdat/services/paths.py +52 -0
  25. ecdat/services/scanner.py +248 -0
  26. ecdat/services/viewmodel.py +326 -0
  27. ecdat/ui/__init__.py +1 -0
  28. ecdat/ui/art3d.py +136 -0
  29. ecdat/ui/art_static.py +65 -0
  30. ecdat/ui/art_text.py +81 -0
  31. ecdat/ui/banner.py +148 -0
  32. ecdat/ui/console.py +119 -0
  33. ecdat/ui/motion.py +64 -0
  34. ecdat/ui/render.py +486 -0
  35. ecdat/ui/theme.py +173 -0
  36. ecdat-0.2.0.dist-info/METADATA +142 -0
  37. ecdat-0.2.0.dist-info/RECORD +51 -0
  38. ecdat-0.2.0.dist-info/WHEEL +5 -0
  39. ecdat-0.2.0.dist-info/entry_points.txt +2 -0
  40. ecdat-0.2.0.dist-info/licenses/LICENSE +21 -0
  41. ecdat-0.2.0.dist-info/top_level.txt +2 -0
  42. ecdat_core/__init__.py +6 -0
  43. ecdat_core/cbom_export.py +287 -0
  44. ecdat_core/cli.py +202 -0
  45. ecdat_core/detector.py +273 -0
  46. ecdat_core/ingestion.py +581 -0
  47. ecdat_core/models.py +145 -0
  48. ecdat_core/recommender.py +74 -0
  49. ecdat_core/risk_engine.py +264 -0
  50. ecdat_core/signature_loader.py +204 -0
  51. ecdat_core/signatures.json +692 -0
@@ -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