codedd-cli 0.1.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.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +118 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +25 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1241 -0
- codedd_cli/auditor/architecture_prompts.py +171 -0
- codedd_cli/auditor/complexity_analyzer.py +942 -0
- codedd_cli/auditor/dependency_scanner.py +2478 -0
- codedd_cli/auditor/file_auditor.py +572 -0
- codedd_cli/auditor/git_stats_collector.py +332 -0
- codedd_cli/auditor/response_parser.py +487 -0
- codedd_cli/auditor/vulnerability_validator.py +324 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +41 -0
- codedd_cli/auth/token_manager.py +87 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1877 -0
- codedd_cli/commands/audits_cmd.py +273 -0
- codedd_cli/commands/auth_cmd.py +230 -0
- codedd_cli/commands/config_cmd.py +454 -0
- codedd_cli/commands/scope_cmd.py +967 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +369 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +271 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +32 -0
- codedd_cli/models/local_directory.py +26 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +247 -0
- codedd_cli/scanner/file_walker.py +207 -0
- codedd_cli/scanner/line_counter.py +75 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +179 -0
- codedd_cli/utils/display.py +493 -0
- codedd_cli/utils/payload_inspector.py +180 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.0.dist-info/METADATA +276 -0
- codedd_cli-0.1.0.dist-info/RECORD +49 -0
- codedd_cli-0.1.0.dist-info/WHEEL +4 -0
- codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local dependency scanning for CLI audits.
|
|
3
|
+
|
|
4
|
+
Ports three server-side modules into a single CLI module:
|
|
5
|
+
- **DependencyProcessor** — manifest file parsing (60+ formats)
|
|
6
|
+
- **ExtensionPackageParser** — source import extraction (25+ languages)
|
|
7
|
+
- **Vulnerability scanner** — OSV API queries via ``httpx``
|
|
8
|
+
|
|
9
|
+
All operations run locally. No source code leaves the machine.
|
|
10
|
+
Only structured metadata (package names, versions, vulnerability counts)
|
|
11
|
+
is transmitted to CodeDD.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
import traceback
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("codedd_cli")
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Result dataclasses
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ManifestResult:
|
|
39
|
+
"""Result from parsing a single dependency manifest file."""
|
|
40
|
+
manifest_path: str # relative path of the manifest file
|
|
41
|
+
repo_name: str
|
|
42
|
+
registry: str # e.g. "npm", "pypi"
|
|
43
|
+
packages: list[dict] # [{name, version, latest_version}]
|
|
44
|
+
error: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ImportResult:
|
|
49
|
+
"""Result from extracting imports from a single source file."""
|
|
50
|
+
file_path: str # cli:// path
|
|
51
|
+
registry_prefix: str # e.g. "$!pypi$!_"
|
|
52
|
+
packages: list[str] # just names (no versions)
|
|
53
|
+
error: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
# SECTION 1 — Manifest Parsers (ported from DependencyProcessor)
|
|
58
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
class ManifestParser:
|
|
61
|
+
"""
|
|
62
|
+
Pure-Python manifest file parser supporting 60+ dependency file formats.
|
|
63
|
+
|
|
64
|
+
Ported wholesale from ``DependencyProcessor`` — all parsing logic is
|
|
65
|
+
identical to the server-side implementation. No Django/TypeDB
|
|
66
|
+
dependencies.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# ----- Validation / normalisation helpers -----
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _validate_package_name(name: str) -> bool:
|
|
73
|
+
"""Return True if *name* looks like a valid package identifier."""
|
|
74
|
+
if not name or not isinstance(name, str):
|
|
75
|
+
return False
|
|
76
|
+
name = name.strip()
|
|
77
|
+
if not name or name in ("", "null", "undefined", "None"):
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _normalize_version(version: str) -> str | None:
|
|
83
|
+
"""Normalise a version string, returning None for wildcards/empties."""
|
|
84
|
+
if not version or not isinstance(version, str):
|
|
85
|
+
return None
|
|
86
|
+
version = version.strip()
|
|
87
|
+
if not version:
|
|
88
|
+
return None
|
|
89
|
+
if version in ("*", "latest", "master", "main", "HEAD"):
|
|
90
|
+
return None
|
|
91
|
+
if version.startswith("v") and len(version) > 1 and version[1].isdigit():
|
|
92
|
+
version = version[1:]
|
|
93
|
+
if not any(c.isalnum() for c in version):
|
|
94
|
+
return None
|
|
95
|
+
return version
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _extract_version_range_bounds(spec: str) -> Tuple[str | None, str | None]:
|
|
99
|
+
"""
|
|
100
|
+
Extract representative lower/upper bounds from a version spec
|
|
101
|
+
like ``"^10.0|^11.0|^12.0"`` or ``"^1.0.0 || ^2.0.0"``.
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(spec, str):
|
|
104
|
+
return None, None
|
|
105
|
+
spec = spec.strip()
|
|
106
|
+
if not spec or spec in ("*", "dev-master"):
|
|
107
|
+
return None, None
|
|
108
|
+
|
|
109
|
+
parts = re.split(r"\s*\|\|?\s*", spec)
|
|
110
|
+
cleaned: list[str] = []
|
|
111
|
+
for part in parts:
|
|
112
|
+
p = part.strip()
|
|
113
|
+
if not p:
|
|
114
|
+
continue
|
|
115
|
+
p = re.sub(r"^[\^~><=!*]+", "", p)
|
|
116
|
+
m = re.search(r"[0-9][0-9A-Za-z.\-]*", p)
|
|
117
|
+
if m:
|
|
118
|
+
cleaned.append(m.group(0))
|
|
119
|
+
|
|
120
|
+
if not cleaned:
|
|
121
|
+
return None, None
|
|
122
|
+
return cleaned[0], cleaned[-1]
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _deduplicate_packages(
|
|
126
|
+
packages: list[tuple[str, str | None, str | None]],
|
|
127
|
+
) -> list[tuple[str, str | None, str | None]]:
|
|
128
|
+
"""Remove duplicates (case-insensitive) and filter invalid names."""
|
|
129
|
+
seen: set[tuple[str, str | None]] = set()
|
|
130
|
+
result: list[tuple[str, str | None, str | None]] = []
|
|
131
|
+
for pkg, ver, latest in packages:
|
|
132
|
+
if not ManifestParser._validate_package_name(pkg):
|
|
133
|
+
continue
|
|
134
|
+
ver = ManifestParser._normalize_version(ver)
|
|
135
|
+
latest = ManifestParser._normalize_version(latest)
|
|
136
|
+
key = (pkg.lower(), ver)
|
|
137
|
+
if key not in seen:
|
|
138
|
+
seen.add(key)
|
|
139
|
+
result.append((pkg, ver, latest))
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
# ----- Dispatch table -----
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _get_parser_dispatch_table() -> dict:
|
|
146
|
+
"""Return filename → parser-function mapping (O(1) lookup)."""
|
|
147
|
+
return {
|
|
148
|
+
# JavaScript / Node.js
|
|
149
|
+
"package.json": ManifestParser._parse_package_json,
|
|
150
|
+
"package-lock.json": ManifestParser._parse_package_lock_json,
|
|
151
|
+
"yarn.lock": ManifestParser._parse_yarn_lock,
|
|
152
|
+
"pnpm-lock.yaml": ManifestParser._parse_pnpm_lock_yaml,
|
|
153
|
+
# Python
|
|
154
|
+
"pyproject.toml": ManifestParser._parse_pyproject_toml,
|
|
155
|
+
"Pipfile": ManifestParser._parse_pipfile,
|
|
156
|
+
"Pipfile.lock": ManifestParser._parse_pipfile_lock,
|
|
157
|
+
"poetry.lock": ManifestParser._parse_poetry_lock,
|
|
158
|
+
"uv.lock": ManifestParser._parse_uv_lock,
|
|
159
|
+
"pdm.lock": ManifestParser._parse_pdm_lock,
|
|
160
|
+
"environment.yml": ManifestParser._parse_conda_environment,
|
|
161
|
+
"conda.yaml": ManifestParser._parse_conda_environment,
|
|
162
|
+
"environment.yaml": ManifestParser._parse_conda_environment,
|
|
163
|
+
# Go
|
|
164
|
+
"go.mod": ManifestParser._parse_go_mod,
|
|
165
|
+
"go.sum": ManifestParser._parse_go_sum,
|
|
166
|
+
"Gopkg.toml": ManifestParser._parse_gopkg_toml,
|
|
167
|
+
"Gopkg.lock": ManifestParser._parse_gopkg_lock,
|
|
168
|
+
# PHP
|
|
169
|
+
"composer.json": ManifestParser._parse_composer_json,
|
|
170
|
+
"composer.lock": ManifestParser._parse_composer_lock,
|
|
171
|
+
# Rust
|
|
172
|
+
"Cargo.toml": ManifestParser._parse_cargo_toml,
|
|
173
|
+
"Cargo.lock": ManifestParser._parse_cargo_lock,
|
|
174
|
+
# Java / Maven
|
|
175
|
+
"pom.xml": ManifestParser._parse_pom_xml,
|
|
176
|
+
# Ruby
|
|
177
|
+
"Gemfile": ManifestParser._parse_gemfile,
|
|
178
|
+
"Gemfile.lock": ManifestParser._parse_gemfile_lock,
|
|
179
|
+
# Gradle / Kotlin
|
|
180
|
+
"build.gradle": ManifestParser._parse_gradle_build,
|
|
181
|
+
"build.gradle.kts": ManifestParser._parse_gradle_build,
|
|
182
|
+
"gradle.properties": ManifestParser._parse_gradle_properties,
|
|
183
|
+
# Scala
|
|
184
|
+
"build.sbt": ManifestParser._parse_build_sbt,
|
|
185
|
+
# .NET / C#
|
|
186
|
+
"packages.config": ManifestParser._parse_dotnet_packages,
|
|
187
|
+
"packages.lock.json": ManifestParser._parse_packages_lock_json,
|
|
188
|
+
# Dart / Flutter
|
|
189
|
+
"pubspec.yaml": ManifestParser._parse_pubspec_yaml,
|
|
190
|
+
"pubspec.lock": ManifestParser._parse_pubspec_lock,
|
|
191
|
+
# Elixir
|
|
192
|
+
"mix.exs": ManifestParser._parse_mix_exs,
|
|
193
|
+
"mix.lock": ManifestParser._parse_mix_lock,
|
|
194
|
+
# Swift
|
|
195
|
+
"Package.swift": ManifestParser._parse_package_swift,
|
|
196
|
+
"Package.resolved": ManifestParser._parse_package_resolved,
|
|
197
|
+
# CocoaPods / Carthage
|
|
198
|
+
"Podfile": ManifestParser._parse_podfile,
|
|
199
|
+
"Podfile.lock": ManifestParser._parse_podfile_lock,
|
|
200
|
+
"Cartfile": ManifestParser._parse_cartfile,
|
|
201
|
+
"Cartfile.resolved": ManifestParser._parse_cartfile_resolved,
|
|
202
|
+
# Haskell
|
|
203
|
+
"stack.yaml": ManifestParser._parse_stack_yaml,
|
|
204
|
+
".cabal": ManifestParser._parse_cabal,
|
|
205
|
+
# Julia
|
|
206
|
+
"Project.toml": ManifestParser._parse_julia_project_toml,
|
|
207
|
+
"Manifest.toml": ManifestParser._parse_julia_manifest_toml,
|
|
208
|
+
# C++
|
|
209
|
+
"vcpkg.json": ManifestParser._parse_vcpkg_json,
|
|
210
|
+
"conanfile.txt": ManifestParser._parse_conanfile_txt,
|
|
211
|
+
"conanfile.py": ManifestParser._parse_conanfile_py,
|
|
212
|
+
"CMakeLists.txt": ManifestParser._parse_cmake_lists,
|
|
213
|
+
# Deno
|
|
214
|
+
"deno.json": ManifestParser._parse_deno_json,
|
|
215
|
+
"deno.jsonc": ManifestParser._parse_deno_json,
|
|
216
|
+
"deno.lock": ManifestParser._parse_deno_lock,
|
|
217
|
+
# Perl
|
|
218
|
+
"cpanfile": ManifestParser._parse_cpanfile,
|
|
219
|
+
"Makefile.PL": ManifestParser._parse_makefile_pl,
|
|
220
|
+
"Build.PL": ManifestParser._parse_makefile_pl,
|
|
221
|
+
# R
|
|
222
|
+
"DESCRIPTION": ManifestParser._parse_r_description,
|
|
223
|
+
"renv.lock": ManifestParser._parse_renv_lock,
|
|
224
|
+
"packrat.lock": ManifestParser._parse_packrat_lock,
|
|
225
|
+
# Lua
|
|
226
|
+
".rockspec": ManifestParser._parse_rockspec,
|
|
227
|
+
# Clojure
|
|
228
|
+
"project.clj": ManifestParser._parse_project_clj,
|
|
229
|
+
"deps.edn": ManifestParser._parse_deps_edn,
|
|
230
|
+
# OCaml
|
|
231
|
+
"dune-project": ManifestParser._parse_dune_project,
|
|
232
|
+
# Nim
|
|
233
|
+
".nimble": ManifestParser._parse_nimble,
|
|
234
|
+
# Zig
|
|
235
|
+
"build.zig.zon": ManifestParser._parse_build_zig_zon,
|
|
236
|
+
# Erlang
|
|
237
|
+
"rebar.config": ManifestParser._parse_rebar_config,
|
|
238
|
+
"rebar.lock": ManifestParser._parse_rebar_lock,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# ----- Lock-file preference (prefer lock over manifest) -----
|
|
242
|
+
|
|
243
|
+
# When both a lock and its manifest exist in the same directory,
|
|
244
|
+
# prefer the lock file (contains exact resolved versions).
|
|
245
|
+
LOCK_FILE_PREFERENCES: dict[str, list[str]] = {
|
|
246
|
+
"package-lock.json": ["package.json"],
|
|
247
|
+
"yarn.lock": ["package.json"],
|
|
248
|
+
"pnpm-lock.yaml": ["package.json"],
|
|
249
|
+
"composer.lock": ["composer.json"],
|
|
250
|
+
"Gemfile.lock": ["Gemfile"],
|
|
251
|
+
"Cargo.lock": ["Cargo.toml"],
|
|
252
|
+
"Pipfile.lock": ["Pipfile"],
|
|
253
|
+
"poetry.lock": ["pyproject.toml"],
|
|
254
|
+
"pubspec.lock": ["pubspec.yaml"],
|
|
255
|
+
"mix.lock": ["mix.exs"],
|
|
256
|
+
"Podfile.lock": ["Podfile"],
|
|
257
|
+
"Cartfile.resolved": ["Cartfile"],
|
|
258
|
+
"deno.lock": ["deno.json", "deno.jsonc"],
|
|
259
|
+
"renv.lock": ["DESCRIPTION"],
|
|
260
|
+
"packrat.lock": ["DESCRIPTION"],
|
|
261
|
+
"rebar.lock": ["rebar.config"],
|
|
262
|
+
"uv.lock": ["pyproject.toml"],
|
|
263
|
+
"pdm.lock": ["pyproject.toml"],
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# ----- Main dispatcher -----
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def parse_dependency_file(
|
|
270
|
+
file_path: str,
|
|
271
|
+
file_content: str,
|
|
272
|
+
) -> list[tuple[str, str | None, str | None]]:
|
|
273
|
+
"""
|
|
274
|
+
Parse a dependency manifest and return a list of
|
|
275
|
+
``(package_name, version, latest_version)`` tuples.
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
filename = os.path.basename(file_path)
|
|
279
|
+
filename_lower = filename.lower()
|
|
280
|
+
normalized_path = os.path.normpath(file_path).lower()
|
|
281
|
+
|
|
282
|
+
dispatch_table = ManifestParser._get_parser_dispatch_table()
|
|
283
|
+
raw_packages: list[tuple] = []
|
|
284
|
+
|
|
285
|
+
# Special cases (path-based patterns)
|
|
286
|
+
if ".github/workflows" in normalized_path and filename_lower.endswith(
|
|
287
|
+
(".yml", ".yaml")
|
|
288
|
+
):
|
|
289
|
+
raw_packages = ManifestParser._parse_github_actions_workflow(file_content)
|
|
290
|
+
elif filename_lower == "opam" or filename_lower.endswith(".opam"):
|
|
291
|
+
raw_packages = ManifestParser._parse_opam(file_content)
|
|
292
|
+
elif filename in dispatch_table:
|
|
293
|
+
raw_packages = dispatch_table[filename](file_content)
|
|
294
|
+
elif filename_lower in dispatch_table:
|
|
295
|
+
raw_packages = dispatch_table[filename_lower](file_content)
|
|
296
|
+
else:
|
|
297
|
+
_, ext = os.path.splitext(filename_lower)
|
|
298
|
+
if ext in dispatch_table:
|
|
299
|
+
raw_packages = dispatch_table[ext](file_content)
|
|
300
|
+
else:
|
|
301
|
+
for pattern, parser_func in dispatch_table.items():
|
|
302
|
+
if file_path.endswith(pattern):
|
|
303
|
+
raw_packages = parser_func(file_content)
|
|
304
|
+
break
|
|
305
|
+
else:
|
|
306
|
+
raw_packages = ManifestParser._parse_requirements_txt(file_content)
|
|
307
|
+
|
|
308
|
+
return ManifestParser._deduplicate_packages(raw_packages)
|
|
309
|
+
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
logger.error("Error parsing dependency file %s: %s", file_path, exc)
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def is_dependency_file(file_path: str) -> bool:
|
|
316
|
+
"""
|
|
317
|
+
Return True if *file_path* looks like a dependency manifest.
|
|
318
|
+
|
|
319
|
+
Uses the dispatch-table keys plus a set of well-known patterns
|
|
320
|
+
(equivalent to the server-side ``FileIdentifier``-backed check).
|
|
321
|
+
"""
|
|
322
|
+
normalized_path = os.path.normpath(file_path).lower()
|
|
323
|
+
filename = os.path.basename(normalized_path)
|
|
324
|
+
|
|
325
|
+
# Build pattern set from dispatch table keys + extras
|
|
326
|
+
dispatch_keys = set(ManifestParser._get_parser_dispatch_table().keys())
|
|
327
|
+
|
|
328
|
+
# Additional known patterns not in dispatch table
|
|
329
|
+
extras = {
|
|
330
|
+
"requirements.txt",
|
|
331
|
+
"requirements-dev.txt",
|
|
332
|
+
"requirements-test.txt",
|
|
333
|
+
"requirements-prod.txt",
|
|
334
|
+
"constraints.txt",
|
|
335
|
+
"dev-requirements.txt",
|
|
336
|
+
"test-requirements.txt",
|
|
337
|
+
"prod-requirements.txt",
|
|
338
|
+
"settings.gradle.kts",
|
|
339
|
+
"ivy.xml",
|
|
340
|
+
"bun.lockb",
|
|
341
|
+
"cabal.project",
|
|
342
|
+
}
|
|
343
|
+
patterns = dispatch_keys | extras
|
|
344
|
+
|
|
345
|
+
for raw in patterns:
|
|
346
|
+
p = raw.lower()
|
|
347
|
+
if p.startswith("."):
|
|
348
|
+
if filename.endswith(p):
|
|
349
|
+
return True
|
|
350
|
+
elif filename == p:
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
# Path-based checks
|
|
354
|
+
if ".github/workflows" in normalized_path and filename.endswith(
|
|
355
|
+
(".yml", ".yaml")
|
|
356
|
+
):
|
|
357
|
+
return True
|
|
358
|
+
if filename == "opam" or filename.endswith(".opam"):
|
|
359
|
+
return True
|
|
360
|
+
if filename.endswith("requirements.txt"):
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def filter_duplicate_dependency_files(file_paths: list[str]) -> list[str]:
|
|
367
|
+
"""
|
|
368
|
+
When both a manifest and its lock file exist in the same directory,
|
|
369
|
+
keep only the lock file to avoid double-counting.
|
|
370
|
+
"""
|
|
371
|
+
files_by_dir: dict[str, list[str]] = defaultdict(list)
|
|
372
|
+
for fp in file_paths:
|
|
373
|
+
if ManifestParser.is_dependency_file(fp):
|
|
374
|
+
dir_path = os.path.dirname(os.path.normpath(fp))
|
|
375
|
+
files_by_dir[dir_path].append(fp)
|
|
376
|
+
|
|
377
|
+
result: list[str] = []
|
|
378
|
+
for dir_path, files in files_by_dir.items():
|
|
379
|
+
file_basenames = {os.path.basename(f).lower(): f for f in files}
|
|
380
|
+
skip_manifests: set[str] = set()
|
|
381
|
+
|
|
382
|
+
for lock_pattern, manifest_patterns in ManifestParser.LOCK_FILE_PREFERENCES.items():
|
|
383
|
+
lock_lower = lock_pattern.lower()
|
|
384
|
+
if lock_lower in file_basenames:
|
|
385
|
+
result.append(file_basenames[lock_lower])
|
|
386
|
+
for mp in manifest_patterns:
|
|
387
|
+
ml = mp.lower()
|
|
388
|
+
if ml in file_basenames:
|
|
389
|
+
skip_manifests.add(file_basenames[ml])
|
|
390
|
+
|
|
391
|
+
for fp in files:
|
|
392
|
+
if fp not in skip_manifests and fp not in result:
|
|
393
|
+
result.append(fp)
|
|
394
|
+
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
398
|
+
# Individual parser methods (ported verbatim from server)
|
|
399
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
400
|
+
|
|
401
|
+
# --- JavaScript / Node.js ---
|
|
402
|
+
|
|
403
|
+
@staticmethod
|
|
404
|
+
def _parse_package_json(file_content: str) -> list[tuple]:
|
|
405
|
+
"""Parse package.json file."""
|
|
406
|
+
package_data = json.loads(file_content)
|
|
407
|
+
pkg_info: list[tuple] = []
|
|
408
|
+
for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
409
|
+
deps = package_data.get(section, {})
|
|
410
|
+
if deps and isinstance(deps, dict):
|
|
411
|
+
pkg_info.extend(ManifestParser._parse_npm_dependencies(deps))
|
|
412
|
+
return pkg_info
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _parse_npm_dependencies(dependencies: dict) -> list[tuple]:
|
|
416
|
+
"""Parse NPM-style dependency dict {name: version_spec}."""
|
|
417
|
+
pkg_info: list[tuple] = []
|
|
418
|
+
try:
|
|
419
|
+
for name, version_spec in dependencies.items():
|
|
420
|
+
version, latest_version = ManifestParser._extract_version_range_bounds(version_spec)
|
|
421
|
+
pkg_info.append((name, version, latest_version))
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
return pkg_info
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _parse_package_lock_json(file_content: str) -> list[tuple]:
|
|
428
|
+
"""Parse package-lock.json (v1 and v2+ formats)."""
|
|
429
|
+
pkg_info: list[tuple] = []
|
|
430
|
+
|
|
431
|
+
def _extract_npm_name(pkg_path: str) -> str | None:
|
|
432
|
+
if not pkg_path:
|
|
433
|
+
return None
|
|
434
|
+
if "node_modules/" in pkg_path:
|
|
435
|
+
name_part = pkg_path.split("node_modules/")[-1]
|
|
436
|
+
else:
|
|
437
|
+
name_part = pkg_path
|
|
438
|
+
name_part = name_part.strip("/")
|
|
439
|
+
if not name_part:
|
|
440
|
+
return None
|
|
441
|
+
if name_part.startswith("@"):
|
|
442
|
+
parts = name_part.split("/")
|
|
443
|
+
return f"{parts[0]}/{parts[1]}" if len(parts) >= 2 else None
|
|
444
|
+
return name_part.split("/")[0]
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
lock_data = json.loads(file_content)
|
|
448
|
+
lockfile_version = lock_data.get("lockfileVersion", 1)
|
|
449
|
+
|
|
450
|
+
if lockfile_version >= 2:
|
|
451
|
+
packages = lock_data.get("packages", {})
|
|
452
|
+
for pkg_path, info in packages.items():
|
|
453
|
+
if pkg_path and isinstance(info, dict) and "version" in info:
|
|
454
|
+
name = _extract_npm_name(pkg_path)
|
|
455
|
+
if name:
|
|
456
|
+
pkg_info.append((name, info["version"], None))
|
|
457
|
+
else:
|
|
458
|
+
dependencies = lock_data.get("dependencies", {})
|
|
459
|
+
for name, info in dependencies.items():
|
|
460
|
+
if isinstance(info, dict) and "version" in info:
|
|
461
|
+
pkg_info.append((name, info["version"], None))
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
return pkg_info
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _parse_yarn_lock(file_content: str) -> list[tuple]:
|
|
468
|
+
"""Parse yarn.lock file."""
|
|
469
|
+
pkg_info: list[tuple] = []
|
|
470
|
+
current_package = None
|
|
471
|
+
for line in file_content.splitlines():
|
|
472
|
+
pkg_match = re.match(r'^"?(@?[^@"]+)@', line)
|
|
473
|
+
if pkg_match:
|
|
474
|
+
current_package = pkg_match.group(1)
|
|
475
|
+
continue
|
|
476
|
+
if current_package:
|
|
477
|
+
ver_match = re.match(r'^\s+version\s+"([^"]+)"', line)
|
|
478
|
+
if ver_match:
|
|
479
|
+
pkg_info.append((current_package, ver_match.group(1), None))
|
|
480
|
+
current_package = None
|
|
481
|
+
return pkg_info
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _parse_pnpm_lock_yaml(file_content: str) -> list[tuple]:
|
|
485
|
+
"""Parse pnpm-lock.yaml file."""
|
|
486
|
+
pkg_info: list[tuple] = []
|
|
487
|
+
|
|
488
|
+
def _extract_pnpm_name(pkg_path: str) -> str | None:
|
|
489
|
+
if not isinstance(pkg_path, str):
|
|
490
|
+
return None
|
|
491
|
+
key = pkg_path.lstrip("/")
|
|
492
|
+
key = key.split("(", 1)[0]
|
|
493
|
+
if "@" in key:
|
|
494
|
+
name_part = key.split("@", 1)[0]
|
|
495
|
+
else:
|
|
496
|
+
name_part = key
|
|
497
|
+
name_part = name_part.strip("/")
|
|
498
|
+
if not name_part:
|
|
499
|
+
return None
|
|
500
|
+
segments = name_part.split("/")
|
|
501
|
+
if not segments:
|
|
502
|
+
return None
|
|
503
|
+
for i, seg in enumerate(segments):
|
|
504
|
+
if seg.startswith("@") and i + 1 < len(segments):
|
|
505
|
+
return f"{seg}/{segments[i + 1]}"
|
|
506
|
+
return segments[-1]
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
import yaml
|
|
510
|
+
lock_data = yaml.safe_load(file_content)
|
|
511
|
+
packages = lock_data.get("packages", {})
|
|
512
|
+
for pkg_path, pkg_data in packages.items():
|
|
513
|
+
if isinstance(pkg_data, dict) and "version" in pkg_data:
|
|
514
|
+
name = _extract_pnpm_name(pkg_path)
|
|
515
|
+
if name:
|
|
516
|
+
pkg_info.append((name, pkg_data["version"], None))
|
|
517
|
+
except Exception:
|
|
518
|
+
# Fallback to regex if yaml not available
|
|
519
|
+
current_raw_key = None
|
|
520
|
+
for line in file_content.splitlines():
|
|
521
|
+
m = re.match(r"^\s*/([^:]+):", line)
|
|
522
|
+
if m:
|
|
523
|
+
current_raw_key = m.group(1)
|
|
524
|
+
ver_match = re.match(r"^\s+version:\s+([0-9][0-9a-zA-Z.\-]+)", line)
|
|
525
|
+
if ver_match and current_raw_key:
|
|
526
|
+
name = _extract_pnpm_name(current_raw_key)
|
|
527
|
+
if name:
|
|
528
|
+
pkg_info.append((name, ver_match.group(1), None))
|
|
529
|
+
current_raw_key = None
|
|
530
|
+
return pkg_info
|
|
531
|
+
|
|
532
|
+
# --- Python ---
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def _parse_pyproject_toml(file_content: str) -> list[tuple]:
|
|
536
|
+
"""Parse pyproject.toml (PEP 621 and Poetry formats)."""
|
|
537
|
+
pkg_info: list[tuple] = []
|
|
538
|
+
try:
|
|
539
|
+
import tomllib # Python 3.11+
|
|
540
|
+
except ModuleNotFoundError:
|
|
541
|
+
try:
|
|
542
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
543
|
+
except ModuleNotFoundError:
|
|
544
|
+
tomllib = None # type: ignore[assignment]
|
|
545
|
+
|
|
546
|
+
toml_data: dict = {}
|
|
547
|
+
if tomllib:
|
|
548
|
+
try:
|
|
549
|
+
toml_data = tomllib.loads(file_content)
|
|
550
|
+
except Exception:
|
|
551
|
+
toml_data = {}
|
|
552
|
+
|
|
553
|
+
if toml_data:
|
|
554
|
+
# PEP 621 format
|
|
555
|
+
for dep_str in toml_data.get("project", {}).get("dependencies", []):
|
|
556
|
+
if isinstance(dep_str, str):
|
|
557
|
+
m = re.match(
|
|
558
|
+
r"^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?\s*"
|
|
559
|
+
r"(?:\(([^)]+)\)|([><=!~]+)\s*([0-9][0-9a-zA-Z.\-]+))?",
|
|
560
|
+
dep_str,
|
|
561
|
+
)
|
|
562
|
+
if m:
|
|
563
|
+
pkg_name = m.group(1)
|
|
564
|
+
version = m.group(2) or m.group(4) if m.group(3) else None
|
|
565
|
+
if version:
|
|
566
|
+
version = re.sub(r"[><=!~]+", "", version).split(",")[0].strip()
|
|
567
|
+
pkg_info.append((pkg_name, version, None))
|
|
568
|
+
|
|
569
|
+
# Poetry format
|
|
570
|
+
poetry_deps = toml_data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
571
|
+
for name, spec in poetry_deps.items():
|
|
572
|
+
if name.lower() == "python":
|
|
573
|
+
continue
|
|
574
|
+
if isinstance(spec, str):
|
|
575
|
+
version = spec
|
|
576
|
+
elif isinstance(spec, dict):
|
|
577
|
+
version = spec.get("version") or None
|
|
578
|
+
else:
|
|
579
|
+
version = None
|
|
580
|
+
pkg_info.append((name, version, None))
|
|
581
|
+
else:
|
|
582
|
+
# Regex fallback for [tool.poetry.dependencies]
|
|
583
|
+
in_block = False
|
|
584
|
+
for line in file_content.splitlines():
|
|
585
|
+
if line.strip().startswith("[tool.poetry.dependencies]"):
|
|
586
|
+
in_block = True
|
|
587
|
+
continue
|
|
588
|
+
if in_block:
|
|
589
|
+
if line.startswith("["):
|
|
590
|
+
break
|
|
591
|
+
m = re.match(
|
|
592
|
+
r'\s*([A-Za-z0-9_\-]+)\s*=\s*(?:"([^"\n]+)"'
|
|
593
|
+
r'|\{[^}]*version\s*=\s*"([^"\n]+)"[^}]*\})',
|
|
594
|
+
line,
|
|
595
|
+
)
|
|
596
|
+
if m:
|
|
597
|
+
ver = m.group(2) or m.group(3)
|
|
598
|
+
pkg_info.append((m.group(1), ver, None))
|
|
599
|
+
return pkg_info
|
|
600
|
+
|
|
601
|
+
@staticmethod
|
|
602
|
+
def _parse_requirements_txt(file_content: str) -> list[tuple]:
|
|
603
|
+
"""Parse requirements.txt and similar Python dependency files."""
|
|
604
|
+
pkg_info: list[tuple] = []
|
|
605
|
+
req_pattern = re.compile(
|
|
606
|
+
r"^\s*"
|
|
607
|
+
r"(?P<name>[A-Za-z0-9_.\-]+)"
|
|
608
|
+
r"(?:\[[^\]]*\])?"
|
|
609
|
+
r"\s*"
|
|
610
|
+
r"(?:(?P<op>===|==|~=|!=|>=|<=|>|<)\s*(?P<ver>[A-Za-z0-9_.\-]+))?"
|
|
611
|
+
r"(?:\s*;.*)?"
|
|
612
|
+
r"\s*$"
|
|
613
|
+
)
|
|
614
|
+
for raw_line in file_content.split("\n"):
|
|
615
|
+
line = raw_line.strip()
|
|
616
|
+
if not line or line.startswith("#"):
|
|
617
|
+
continue
|
|
618
|
+
if line.startswith(("-r ", "--requirement ", "-e ", "--editable ")) or "://" in line or line.startswith("git+"):
|
|
619
|
+
continue
|
|
620
|
+
m = req_pattern.match(line)
|
|
621
|
+
if not m:
|
|
622
|
+
continue
|
|
623
|
+
pkg = m.group("name")
|
|
624
|
+
op = m.group("op")
|
|
625
|
+
ver = m.group("ver")
|
|
626
|
+
version = ver if ver else None
|
|
627
|
+
latest_version = ver if ver and op in (">=", "~=") else None
|
|
628
|
+
pkg_info.append((pkg, version, latest_version))
|
|
629
|
+
return pkg_info
|
|
630
|
+
|
|
631
|
+
@staticmethod
|
|
632
|
+
def _parse_pipfile(file_content: str) -> list[tuple]:
|
|
633
|
+
"""Parse Pipfile."""
|
|
634
|
+
pkg_info: list[tuple] = []
|
|
635
|
+
in_packages = None
|
|
636
|
+
for line in file_content.splitlines():
|
|
637
|
+
stripped = line.strip()
|
|
638
|
+
if stripped.startswith("[packages]"):
|
|
639
|
+
in_packages = "packages"
|
|
640
|
+
continue
|
|
641
|
+
if stripped.startswith("[dev-packages]"):
|
|
642
|
+
in_packages = "dev"
|
|
643
|
+
continue
|
|
644
|
+
if stripped.startswith("["):
|
|
645
|
+
in_packages = None
|
|
646
|
+
if in_packages and "=" in stripped:
|
|
647
|
+
m = re.match(r'([A-Za-z0-9_\-]+)\s*=\s*["{]*([^"}\n]+)', stripped)
|
|
648
|
+
if m:
|
|
649
|
+
version = m.group(2).strip().lstrip("=~><*")
|
|
650
|
+
version = None if version in ("*", "") else version
|
|
651
|
+
pkg_info.append((m.group(1), version, None))
|
|
652
|
+
return pkg_info
|
|
653
|
+
|
|
654
|
+
@staticmethod
|
|
655
|
+
def _parse_pipfile_lock(file_content: str) -> list[tuple]:
|
|
656
|
+
"""Parse Pipfile.lock."""
|
|
657
|
+
pkg_info: list[tuple] = []
|
|
658
|
+
try:
|
|
659
|
+
lock_data = json.loads(file_content)
|
|
660
|
+
for section in ("default", "develop"):
|
|
661
|
+
deps = lock_data.get(section, {})
|
|
662
|
+
for pkg_name, pkg_data in deps.items():
|
|
663
|
+
if isinstance(pkg_data, dict):
|
|
664
|
+
version = pkg_data.get("version", "").lstrip("=")
|
|
665
|
+
if version:
|
|
666
|
+
pkg_info.append((pkg_name, version, None))
|
|
667
|
+
except Exception:
|
|
668
|
+
pass
|
|
669
|
+
return pkg_info
|
|
670
|
+
|
|
671
|
+
@staticmethod
|
|
672
|
+
def _parse_poetry_lock(file_content: str) -> list[tuple]:
|
|
673
|
+
"""Parse poetry.lock."""
|
|
674
|
+
pkg_info: list[tuple] = []
|
|
675
|
+
current_package: dict | None = None
|
|
676
|
+
for line in file_content.splitlines():
|
|
677
|
+
if line.strip() == "[[package]]":
|
|
678
|
+
current_package = {}
|
|
679
|
+
continue
|
|
680
|
+
if current_package is not None:
|
|
681
|
+
name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
|
|
682
|
+
if name_match:
|
|
683
|
+
current_package["name"] = name_match.group(1)
|
|
684
|
+
version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
|
685
|
+
if version_match:
|
|
686
|
+
current_package["version"] = version_match.group(1)
|
|
687
|
+
if "name" in current_package and "version" in current_package:
|
|
688
|
+
pkg_info.append((current_package["name"], current_package["version"], None))
|
|
689
|
+
current_package = None
|
|
690
|
+
return pkg_info
|
|
691
|
+
|
|
692
|
+
@staticmethod
|
|
693
|
+
def _parse_uv_lock(file_content: str) -> list[tuple]:
|
|
694
|
+
"""Parse uv.lock file."""
|
|
695
|
+
pkg_info: list[tuple] = []
|
|
696
|
+
current_package: dict | None = None
|
|
697
|
+
for line in file_content.splitlines():
|
|
698
|
+
if line.strip() == "[[package]]":
|
|
699
|
+
current_package = {}
|
|
700
|
+
continue
|
|
701
|
+
if current_package is not None:
|
|
702
|
+
name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
|
|
703
|
+
if name_match:
|
|
704
|
+
current_package["name"] = name_match.group(1)
|
|
705
|
+
version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
|
706
|
+
if version_match:
|
|
707
|
+
current_package["version"] = version_match.group(1)
|
|
708
|
+
if "name" in current_package and "version" in current_package:
|
|
709
|
+
pkg_info.append((current_package["name"], current_package["version"], None))
|
|
710
|
+
current_package = None
|
|
711
|
+
return pkg_info
|
|
712
|
+
|
|
713
|
+
@staticmethod
|
|
714
|
+
def _parse_pdm_lock(file_content: str) -> list[tuple]:
|
|
715
|
+
"""Parse pdm.lock file."""
|
|
716
|
+
pkg_info: list[tuple] = []
|
|
717
|
+
current_package: dict | None = None
|
|
718
|
+
for line in file_content.splitlines():
|
|
719
|
+
if line.strip() == "[[package]]":
|
|
720
|
+
current_package = {}
|
|
721
|
+
continue
|
|
722
|
+
if current_package is not None:
|
|
723
|
+
name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
|
|
724
|
+
if name_match:
|
|
725
|
+
current_package["name"] = name_match.group(1)
|
|
726
|
+
version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
|
727
|
+
if version_match:
|
|
728
|
+
current_package["version"] = version_match.group(1)
|
|
729
|
+
if "name" in current_package and "version" in current_package:
|
|
730
|
+
pkg_info.append((current_package["name"], current_package["version"], None))
|
|
731
|
+
current_package = None
|
|
732
|
+
return pkg_info
|
|
733
|
+
|
|
734
|
+
@staticmethod
|
|
735
|
+
def _parse_conda_environment(file_content: str) -> list[tuple]:
|
|
736
|
+
"""Parse conda environment.yml files."""
|
|
737
|
+
pkg_info: list[tuple] = []
|
|
738
|
+
in_deps = False
|
|
739
|
+
in_pip = False
|
|
740
|
+
for line in file_content.splitlines():
|
|
741
|
+
if line.strip().startswith("dependencies:"):
|
|
742
|
+
in_deps = True
|
|
743
|
+
continue
|
|
744
|
+
if in_deps:
|
|
745
|
+
if line.strip() == "- pip:":
|
|
746
|
+
in_pip = True
|
|
747
|
+
continue
|
|
748
|
+
if in_pip and line and not line.startswith(" ") and not line.strip().startswith("#"):
|
|
749
|
+
in_pip = False
|
|
750
|
+
if in_pip:
|
|
751
|
+
m = re.match(r"\s+-\s*([A-Za-z0-9_.\-]+)\s*([><=!~]+)\s*([A-Za-z0-9.\-]+)", line)
|
|
752
|
+
if m:
|
|
753
|
+
pkg_info.append((m.group(1), m.group(3), None))
|
|
754
|
+
continue
|
|
755
|
+
m2 = re.match(r"\s+-\s*([A-Za-z0-9_.\-]+)\s*$", line)
|
|
756
|
+
if m2:
|
|
757
|
+
pkg_info.append((m2.group(1), None, None))
|
|
758
|
+
continue
|
|
759
|
+
if not in_pip:
|
|
760
|
+
m = re.match(r"\s*-\s*([A-Za-z0-9_\-]+)(?:[=]+([A-Za-z0-9.\-]+))?", line)
|
|
761
|
+
if m:
|
|
762
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
763
|
+
elif line and not line.startswith(" -") and not line.strip().startswith("#"):
|
|
764
|
+
break
|
|
765
|
+
return pkg_info
|
|
766
|
+
|
|
767
|
+
# --- Go ---
|
|
768
|
+
|
|
769
|
+
@staticmethod
|
|
770
|
+
def _parse_go_mod(file_content: str) -> list[tuple]:
|
|
771
|
+
"""Parse go.mod file."""
|
|
772
|
+
pkg_info: list[tuple] = []
|
|
773
|
+
in_block = False
|
|
774
|
+
for line in file_content.splitlines():
|
|
775
|
+
if re.match(r"^\s*require\s*\(", line):
|
|
776
|
+
in_block = True
|
|
777
|
+
continue
|
|
778
|
+
if in_block and line.strip() == ")":
|
|
779
|
+
in_block = False
|
|
780
|
+
continue
|
|
781
|
+
if in_block:
|
|
782
|
+
m = re.match(r"^\s*([\w\-./]+)\s+(v?[0-9][^\s]*)", line)
|
|
783
|
+
if m:
|
|
784
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
785
|
+
continue
|
|
786
|
+
m2 = re.match(r"^\s*require\s+([\w\-./]+)\s+(v?[0-9][^\s]*)", line)
|
|
787
|
+
if m2:
|
|
788
|
+
pkg_info.append((m2.group(1), m2.group(2), None))
|
|
789
|
+
return pkg_info
|
|
790
|
+
|
|
791
|
+
@staticmethod
|
|
792
|
+
def _parse_go_sum(file_content: str) -> list[tuple]:
|
|
793
|
+
"""Parse go.sum file (deduplicated — skips /go.mod entries)."""
|
|
794
|
+
pkg_info: list[tuple] = []
|
|
795
|
+
seen: set[tuple[str, str]] = set()
|
|
796
|
+
for line in file_content.splitlines():
|
|
797
|
+
m = re.match(r"^([\w\-./]+)\s+(v?[0-9][^\s/]+)", line)
|
|
798
|
+
if m:
|
|
799
|
+
key = (m.group(1), m.group(2))
|
|
800
|
+
if key not in seen:
|
|
801
|
+
seen.add(key)
|
|
802
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
803
|
+
return pkg_info
|
|
804
|
+
|
|
805
|
+
@staticmethod
|
|
806
|
+
def _parse_gopkg_toml(file_content: str) -> list[tuple]:
|
|
807
|
+
"""Parse Gopkg.toml file."""
|
|
808
|
+
pkg_info: list[tuple] = []
|
|
809
|
+
current_package: dict | None = None
|
|
810
|
+
for line in file_content.splitlines():
|
|
811
|
+
if line.strip() in ("[[constraint]]", "[[override]]"):
|
|
812
|
+
current_package = {}
|
|
813
|
+
continue
|
|
814
|
+
if current_package is not None:
|
|
815
|
+
name_match = re.match(r'^\s*name\s*=\s*"([^"]+)"', line)
|
|
816
|
+
if name_match:
|
|
817
|
+
current_package["name"] = name_match.group(1)
|
|
818
|
+
version_match = re.match(r'^\s*version\s*=\s*"([^"]+)"', line)
|
|
819
|
+
if version_match:
|
|
820
|
+
current_package["version"] = version_match.group(1)
|
|
821
|
+
if "name" in current_package and "version" in current_package:
|
|
822
|
+
pkg_info.append((current_package["name"], current_package["version"], None))
|
|
823
|
+
current_package = None
|
|
824
|
+
return pkg_info
|
|
825
|
+
|
|
826
|
+
@staticmethod
|
|
827
|
+
def _parse_gopkg_lock(file_content: str) -> list[tuple]:
|
|
828
|
+
"""Parse Gopkg.lock file."""
|
|
829
|
+
pkg_info: list[tuple] = []
|
|
830
|
+
current_package: dict | None = None
|
|
831
|
+
for line in file_content.splitlines():
|
|
832
|
+
if line.strip() == "[[projects]]":
|
|
833
|
+
current_package = {}
|
|
834
|
+
continue
|
|
835
|
+
if current_package is not None:
|
|
836
|
+
name_match = re.match(r'^\s*name\s*=\s*"([^"]+)"', line)
|
|
837
|
+
if name_match:
|
|
838
|
+
current_package["name"] = name_match.group(1)
|
|
839
|
+
version_match = re.match(r'^\s*version\s*=\s*"([^"]+)"', line)
|
|
840
|
+
if version_match:
|
|
841
|
+
current_package["version"] = version_match.group(1).lstrip("v")
|
|
842
|
+
revision_match = re.match(r'^\s*revision\s*=\s*"([^"]+)"', line)
|
|
843
|
+
if revision_match and "version" not in current_package:
|
|
844
|
+
current_package["version"] = revision_match.group(1)[:7]
|
|
845
|
+
if "name" in current_package and "version" in current_package:
|
|
846
|
+
pkg_info.append((current_package["name"], current_package["version"], None))
|
|
847
|
+
current_package = None
|
|
848
|
+
return pkg_info
|
|
849
|
+
|
|
850
|
+
# --- PHP ---
|
|
851
|
+
|
|
852
|
+
@staticmethod
|
|
853
|
+
def _parse_composer_json(file_content: str) -> list[tuple]:
|
|
854
|
+
"""Parse composer.json file."""
|
|
855
|
+
pkg_info: list[tuple] = []
|
|
856
|
+
comp_data = json.loads(file_content)
|
|
857
|
+
require_deps = comp_data.get("require", {}) or {}
|
|
858
|
+
dev_deps = comp_data.get("require-dev", {}) or {}
|
|
859
|
+
merged = dict(require_deps)
|
|
860
|
+
for k, v in dev_deps.items():
|
|
861
|
+
merged.setdefault(k, v)
|
|
862
|
+
for pkg, raw_spec in merged.items():
|
|
863
|
+
version, latest_version = ManifestParser._extract_version_range_bounds(raw_spec)
|
|
864
|
+
pkg_info.append((pkg, version, latest_version))
|
|
865
|
+
return pkg_info
|
|
866
|
+
|
|
867
|
+
@staticmethod
|
|
868
|
+
def _parse_composer_lock(file_content: str) -> list[tuple]:
|
|
869
|
+
"""Parse composer.lock file."""
|
|
870
|
+
pkg_info: list[tuple] = []
|
|
871
|
+
try:
|
|
872
|
+
lock_data = json.loads(file_content)
|
|
873
|
+
for section in ("packages", "packages-dev"):
|
|
874
|
+
for pkg in lock_data.get(section, []):
|
|
875
|
+
if isinstance(pkg, dict):
|
|
876
|
+
name = pkg.get("name")
|
|
877
|
+
version = (pkg.get("version") or "").lstrip("v")
|
|
878
|
+
if name and version:
|
|
879
|
+
pkg_info.append((name, version, version))
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
return pkg_info
|
|
883
|
+
|
|
884
|
+
# --- Rust ---
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _parse_cargo_toml(file_content: str) -> list[tuple]:
|
|
888
|
+
"""Parse Cargo.toml file."""
|
|
889
|
+
pkg_info: list[tuple] = []
|
|
890
|
+
in_block = False
|
|
891
|
+
for line in file_content.splitlines():
|
|
892
|
+
if re.match(r"^\s*\[(dependencies|dev-dependencies|build-dependencies)\]", line):
|
|
893
|
+
in_block = True
|
|
894
|
+
continue
|
|
895
|
+
if in_block:
|
|
896
|
+
if line.strip().startswith("["):
|
|
897
|
+
in_block = False
|
|
898
|
+
continue
|
|
899
|
+
m = re.match(r'\s*([A-Za-z0-9_\-]+)\s*=\s*"([^"]+)"', line)
|
|
900
|
+
if m:
|
|
901
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
902
|
+
continue
|
|
903
|
+
m2 = re.match(r'\s*([A-Za-z0-9_\-]+)\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"', line)
|
|
904
|
+
if m2:
|
|
905
|
+
pkg_info.append((m2.group(1), m2.group(2), None))
|
|
906
|
+
return pkg_info
|
|
907
|
+
|
|
908
|
+
@staticmethod
|
|
909
|
+
def _parse_cargo_lock(file_content: str) -> list[tuple]:
|
|
910
|
+
"""Parse Cargo.lock file."""
|
|
911
|
+
pkg_info: list[tuple] = []
|
|
912
|
+
current_package: dict | None = None
|
|
913
|
+
for line in file_content.splitlines():
|
|
914
|
+
if line.strip() == "[[package]]":
|
|
915
|
+
current_package = {}
|
|
916
|
+
continue
|
|
917
|
+
if current_package is not None:
|
|
918
|
+
name_match = re.match(r'^name\s*=\s*"([^"]+)"', line)
|
|
919
|
+
if name_match:
|
|
920
|
+
current_package["name"] = name_match.group(1)
|
|
921
|
+
version_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
|
922
|
+
if version_match:
|
|
923
|
+
current_package["version"] = version_match.group(1)
|
|
924
|
+
if "name" in current_package and "version" in current_package:
|
|
925
|
+
pkg_info.append((current_package["name"], current_package["version"], None))
|
|
926
|
+
current_package = None
|
|
927
|
+
return pkg_info
|
|
928
|
+
|
|
929
|
+
# --- Java / Maven ---
|
|
930
|
+
|
|
931
|
+
@staticmethod
|
|
932
|
+
def _parse_pom_xml(file_content: str) -> list[tuple]:
|
|
933
|
+
"""Parse pom.xml file."""
|
|
934
|
+
pkg_info: list[tuple] = []
|
|
935
|
+
try:
|
|
936
|
+
import xml.etree.ElementTree as ET
|
|
937
|
+
root = ET.fromstring(file_content)
|
|
938
|
+
for dep in root.iterfind(".//{*}dependency"):
|
|
939
|
+
g = dep.find("{*}groupId")
|
|
940
|
+
a = dep.find("{*}artifactId")
|
|
941
|
+
v = dep.find("{*}version")
|
|
942
|
+
if g is not None and a is not None:
|
|
943
|
+
name = f"{g.text}:{a.text}"
|
|
944
|
+
version = v.text if v is not None else None
|
|
945
|
+
pkg_info.append((name, version, None))
|
|
946
|
+
except Exception:
|
|
947
|
+
pass
|
|
948
|
+
return pkg_info
|
|
949
|
+
|
|
950
|
+
# --- Ruby ---
|
|
951
|
+
|
|
952
|
+
@staticmethod
|
|
953
|
+
def _parse_gemfile(file_content: str) -> list[tuple]:
|
|
954
|
+
"""Parse Gemfile."""
|
|
955
|
+
pkg_info: list[tuple] = []
|
|
956
|
+
for line in file_content.splitlines():
|
|
957
|
+
if line.strip().startswith("#") or not line.strip():
|
|
958
|
+
continue
|
|
959
|
+
m = re.match(r"""^\s*gem\s+['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]""", line)
|
|
960
|
+
if m:
|
|
961
|
+
pkg_info.append((m.group(1), m.group(2).lstrip("~>>=<"), None))
|
|
962
|
+
else:
|
|
963
|
+
m2 = re.match(r"""^\s*gem\s+['"]([^'"]+)['"]""", line)
|
|
964
|
+
if m2:
|
|
965
|
+
pkg_info.append((m2.group(1), None, None))
|
|
966
|
+
return pkg_info
|
|
967
|
+
|
|
968
|
+
@staticmethod
|
|
969
|
+
def _parse_gemfile_lock(file_content: str) -> list[tuple]:
|
|
970
|
+
"""Parse Gemfile.lock."""
|
|
971
|
+
pkg_info: list[tuple] = []
|
|
972
|
+
in_specs = False
|
|
973
|
+
for line in file_content.splitlines():
|
|
974
|
+
if line.strip() == "specs:":
|
|
975
|
+
in_specs = True
|
|
976
|
+
continue
|
|
977
|
+
if in_specs:
|
|
978
|
+
m = re.match(r"^\s{4,}([A-Za-z0-9_\-]+)\s+\(([0-9][0-9a-zA-Z.\-]*)\)", line)
|
|
979
|
+
if m:
|
|
980
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
981
|
+
elif line and not line.startswith(" "):
|
|
982
|
+
in_specs = False
|
|
983
|
+
return pkg_info
|
|
984
|
+
|
|
985
|
+
# --- Gradle / Kotlin ---
|
|
986
|
+
|
|
987
|
+
@staticmethod
|
|
988
|
+
def _parse_gradle_build(file_content: str) -> list[tuple]:
|
|
989
|
+
"""Parse build.gradle or build.gradle.kts."""
|
|
990
|
+
pkg_info: list[tuple] = []
|
|
991
|
+
gradle_pattern = re.compile(
|
|
992
|
+
r"\b(?:implementation|api|compile|compileOnly|testImplementation|runtimeOnly|"
|
|
993
|
+
r"kapt|annotationProcessor|classpath|testCompile|testRuntime|runtimeClasspath|"
|
|
994
|
+
r"compileClasspath|testRuntimeClasspath)\s*[\(\[\{]?\s*['\"]([^:'\"]+"
|
|
995
|
+
r"):([^:'\"]+):([^:'\"]+)['\"]"
|
|
996
|
+
)
|
|
997
|
+
for m in gradle_pattern.finditer(file_content):
|
|
998
|
+
group, artifact, version = m.groups()
|
|
999
|
+
pkg_info.append((f"{group}:{artifact}", version, None))
|
|
1000
|
+
|
|
1001
|
+
map_pattern = re.compile(
|
|
1002
|
+
r"group:\s*['\"]([^'\"]+)['\"],\s*name:\s*['\"]([^'\"]+)['\"]"
|
|
1003
|
+
r",\s*version:\s*['\"]([^'\"]+)['\"]"
|
|
1004
|
+
)
|
|
1005
|
+
for m in map_pattern.finditer(file_content):
|
|
1006
|
+
group, artifact, version = m.groups()
|
|
1007
|
+
pkg_info.append((f"{group}:{artifact}", version, None))
|
|
1008
|
+
return pkg_info
|
|
1009
|
+
|
|
1010
|
+
@staticmethod
|
|
1011
|
+
def _parse_gradle_properties(file_content: str) -> list[tuple]:
|
|
1012
|
+
"""Parse gradle.properties file."""
|
|
1013
|
+
pkg_info: list[tuple] = []
|
|
1014
|
+
for line in file_content.splitlines():
|
|
1015
|
+
m = re.match(r"^\s*([A-Za-z0-9_]+Version)\s*=\s*([0-9][0-9a-zA-Z.\-]*)", line)
|
|
1016
|
+
if m:
|
|
1017
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1018
|
+
return pkg_info
|
|
1019
|
+
|
|
1020
|
+
# --- Scala ---
|
|
1021
|
+
|
|
1022
|
+
@staticmethod
|
|
1023
|
+
def _parse_build_sbt(file_content: str) -> list[tuple]:
|
|
1024
|
+
"""Parse build.sbt file."""
|
|
1025
|
+
pkg_info: list[tuple] = []
|
|
1026
|
+
for line in file_content.splitlines():
|
|
1027
|
+
m = re.match(
|
|
1028
|
+
r'^\s*libraryDependencies\s*\+[=+]\s*"([^"]+)"\s*%%?\s*"([^"]+)"\s*%\s*"([^"]+)"',
|
|
1029
|
+
line,
|
|
1030
|
+
)
|
|
1031
|
+
if m:
|
|
1032
|
+
org, artifact, version = m.groups()
|
|
1033
|
+
pkg_info.append((f"{org}:{artifact}", version, None))
|
|
1034
|
+
continue
|
|
1035
|
+
for org, artifact, version in re.findall(
|
|
1036
|
+
r'"([^"]+)"\s*%%?\s*"([^"]+)"\s*%\s*"([^"]+)"', line
|
|
1037
|
+
):
|
|
1038
|
+
pkg_info.append((f"{org}:{artifact}", version, None))
|
|
1039
|
+
return pkg_info
|
|
1040
|
+
|
|
1041
|
+
# --- .NET / C# ---
|
|
1042
|
+
|
|
1043
|
+
@staticmethod
|
|
1044
|
+
def _parse_dotnet_packages(file_content: str) -> list[tuple]:
|
|
1045
|
+
"""Parse .csproj or packages.config files."""
|
|
1046
|
+
pkg_info: list[tuple] = []
|
|
1047
|
+
try:
|
|
1048
|
+
import xml.etree.ElementTree as ET
|
|
1049
|
+
root = ET.fromstring(file_content)
|
|
1050
|
+
for pr in root.iterfind(".//{*}PackageReference"):
|
|
1051
|
+
inc = pr.attrib.get("Include") or pr.attrib.get("Update")
|
|
1052
|
+
ver = pr.attrib.get("Version") or pr.findtext("{*}Version")
|
|
1053
|
+
if inc:
|
|
1054
|
+
pkg_info.append((inc, ver, None))
|
|
1055
|
+
for pkg in root.iterfind(".//{*}package"):
|
|
1056
|
+
id_ = pkg.attrib.get("id")
|
|
1057
|
+
ver = pkg.attrib.get("version")
|
|
1058
|
+
if id_:
|
|
1059
|
+
pkg_info.append((id_, ver, None))
|
|
1060
|
+
except Exception:
|
|
1061
|
+
pass
|
|
1062
|
+
return pkg_info
|
|
1063
|
+
|
|
1064
|
+
@staticmethod
|
|
1065
|
+
def _parse_packages_lock_json(file_content: str) -> list[tuple]:
|
|
1066
|
+
"""Parse packages.lock.json file."""
|
|
1067
|
+
pkg_info: list[tuple] = []
|
|
1068
|
+
try:
|
|
1069
|
+
lock_data = json.loads(file_content)
|
|
1070
|
+
dependencies = lock_data.get("dependencies", {})
|
|
1071
|
+
for _target, deps in dependencies.items():
|
|
1072
|
+
if isinstance(deps, dict):
|
|
1073
|
+
for pkg_name, pkg_data in deps.items():
|
|
1074
|
+
if isinstance(pkg_data, dict):
|
|
1075
|
+
version = pkg_data.get("resolved") or pkg_data.get("version")
|
|
1076
|
+
if version:
|
|
1077
|
+
pkg_info.append((pkg_name, version, None))
|
|
1078
|
+
except Exception:
|
|
1079
|
+
pass
|
|
1080
|
+
return pkg_info
|
|
1081
|
+
|
|
1082
|
+
# --- Dart / Flutter ---
|
|
1083
|
+
|
|
1084
|
+
@staticmethod
|
|
1085
|
+
def _parse_pubspec_yaml(file_content: str) -> list[tuple]:
|
|
1086
|
+
"""Parse pubspec.yaml file."""
|
|
1087
|
+
pkg_info: list[tuple] = []
|
|
1088
|
+
in_deps = False
|
|
1089
|
+
for line in file_content.splitlines():
|
|
1090
|
+
if re.match(r"^(dependencies|dev_dependencies):\s*$", line):
|
|
1091
|
+
in_deps = True
|
|
1092
|
+
continue
|
|
1093
|
+
if in_deps:
|
|
1094
|
+
if re.match(r"^\S", line):
|
|
1095
|
+
in_deps = False
|
|
1096
|
+
continue
|
|
1097
|
+
m = re.match(r"\s*([A-Za-z0-9_\-]+):\s*[\^~]?([0-9][0-9.\-]*)", line)
|
|
1098
|
+
if m:
|
|
1099
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1100
|
+
return pkg_info
|
|
1101
|
+
|
|
1102
|
+
@staticmethod
|
|
1103
|
+
def _parse_pubspec_lock(file_content: str) -> list[tuple]:
|
|
1104
|
+
"""Parse pubspec.lock file."""
|
|
1105
|
+
pkg_info: list[tuple] = []
|
|
1106
|
+
in_packages = False
|
|
1107
|
+
current_package = None
|
|
1108
|
+
for line in file_content.splitlines():
|
|
1109
|
+
if line.strip() == "packages:":
|
|
1110
|
+
in_packages = True
|
|
1111
|
+
continue
|
|
1112
|
+
if in_packages:
|
|
1113
|
+
pkg_match = re.match(r"^\s{2}([A-Za-z0-9_\-]+):", line)
|
|
1114
|
+
if pkg_match:
|
|
1115
|
+
current_package = pkg_match.group(1)
|
|
1116
|
+
continue
|
|
1117
|
+
if current_package:
|
|
1118
|
+
ver_match = re.match(r'^\s+version:\s*"([^"]+)"', line)
|
|
1119
|
+
if ver_match:
|
|
1120
|
+
pkg_info.append((current_package, ver_match.group(1), None))
|
|
1121
|
+
current_package = None
|
|
1122
|
+
return pkg_info
|
|
1123
|
+
|
|
1124
|
+
# --- Elixir ---
|
|
1125
|
+
|
|
1126
|
+
@staticmethod
|
|
1127
|
+
def _parse_mix_exs(file_content: str) -> list[tuple]:
|
|
1128
|
+
"""Parse mix.exs file."""
|
|
1129
|
+
pkg_info: list[tuple] = []
|
|
1130
|
+
for m in re.finditer(r'\{\s*:([A-Za-z0-9_]+)\s*,\s*"([^"]+)"', file_content):
|
|
1131
|
+
pkg, ver = m.groups()
|
|
1132
|
+
pkg_info.append((pkg, ver.lstrip("~><="), None))
|
|
1133
|
+
return pkg_info
|
|
1134
|
+
|
|
1135
|
+
@staticmethod
|
|
1136
|
+
def _parse_mix_lock(file_content: str) -> list[tuple]:
|
|
1137
|
+
"""Parse mix.lock file."""
|
|
1138
|
+
pkg_info: list[tuple] = []
|
|
1139
|
+
for m in re.finditer(
|
|
1140
|
+
r'"([A-Za-z0-9_]+)":\s*\{:hex,\s*:[A-Za-z0-9_]+,\s*"([0-9.]+)"',
|
|
1141
|
+
file_content,
|
|
1142
|
+
):
|
|
1143
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1144
|
+
return pkg_info
|
|
1145
|
+
|
|
1146
|
+
# --- Swift ---
|
|
1147
|
+
|
|
1148
|
+
@staticmethod
|
|
1149
|
+
def _parse_package_swift(file_content: str) -> list[tuple]:
|
|
1150
|
+
"""Parse Package.swift file."""
|
|
1151
|
+
pkg_info: list[tuple] = []
|
|
1152
|
+
for m in re.finditer(
|
|
1153
|
+
r'\.package\s*\(.*?url:\s*"https://github\.com/([A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)'
|
|
1154
|
+
r'.*?from:\s*"([0-9.]+)"',
|
|
1155
|
+
file_content,
|
|
1156
|
+
re.DOTALL,
|
|
1157
|
+
):
|
|
1158
|
+
repo, ver = m.groups()
|
|
1159
|
+
pkg_info.append((f"github.com/{repo}", ver, None))
|
|
1160
|
+
for m in re.finditer(
|
|
1161
|
+
r'\.package\s*\(.*?url:\s*"https://github\.com/([A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)'
|
|
1162
|
+
r'.*?\.exact\("([0-9.]+)"\)',
|
|
1163
|
+
file_content,
|
|
1164
|
+
re.DOTALL,
|
|
1165
|
+
):
|
|
1166
|
+
repo, ver = m.groups()
|
|
1167
|
+
pkg_info.append((f"github.com/{repo}", ver, None))
|
|
1168
|
+
return pkg_info
|
|
1169
|
+
|
|
1170
|
+
@staticmethod
|
|
1171
|
+
def _parse_package_resolved(file_content: str) -> list[tuple]:
|
|
1172
|
+
"""Parse Package.resolved file."""
|
|
1173
|
+
pkg_info: list[tuple] = []
|
|
1174
|
+
try:
|
|
1175
|
+
resolved_data = json.loads(file_content)
|
|
1176
|
+
pins = resolved_data.get("pins", []) or resolved_data.get("object", {}).get("pins", [])
|
|
1177
|
+
for pin in pins:
|
|
1178
|
+
if isinstance(pin, dict):
|
|
1179
|
+
identity = pin.get("identity") or pin.get("package")
|
|
1180
|
+
location = pin.get("location", "")
|
|
1181
|
+
state = pin.get("state", {})
|
|
1182
|
+
version = state.get("version")
|
|
1183
|
+
branch = state.get("branch")
|
|
1184
|
+
revision = state.get("revision")
|
|
1185
|
+
version_id = None
|
|
1186
|
+
if version:
|
|
1187
|
+
version_id = version
|
|
1188
|
+
elif branch:
|
|
1189
|
+
version_id = f"branch:{branch}"
|
|
1190
|
+
elif revision:
|
|
1191
|
+
version_id = revision[:12] if len(revision) > 12 else revision
|
|
1192
|
+
package_name = identity
|
|
1193
|
+
if location:
|
|
1194
|
+
github_match = re.search(r"github\.com[:/]([^/]+/[^/]+)", location)
|
|
1195
|
+
if github_match:
|
|
1196
|
+
repo_path = github_match.group(1).rstrip(".git")
|
|
1197
|
+
package_name = f"github.com/{repo_path}"
|
|
1198
|
+
if identity and version_id:
|
|
1199
|
+
pkg_info.append((package_name, version_id, None))
|
|
1200
|
+
except Exception:
|
|
1201
|
+
pass
|
|
1202
|
+
return pkg_info
|
|
1203
|
+
|
|
1204
|
+
# --- CocoaPods / Carthage ---
|
|
1205
|
+
|
|
1206
|
+
@staticmethod
|
|
1207
|
+
def _parse_podfile(file_content: str) -> list[tuple]:
|
|
1208
|
+
"""Parse Podfile."""
|
|
1209
|
+
pkg_info: list[tuple] = []
|
|
1210
|
+
for line in file_content.splitlines():
|
|
1211
|
+
m = re.match(r"""^\s*pod\s+['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]""", line)
|
|
1212
|
+
if m:
|
|
1213
|
+
pkg_info.append((m.group(1), m.group(2).lstrip("~><="), None))
|
|
1214
|
+
return pkg_info
|
|
1215
|
+
|
|
1216
|
+
@staticmethod
|
|
1217
|
+
def _parse_podfile_lock(file_content: str) -> list[tuple]:
|
|
1218
|
+
"""Parse Podfile.lock."""
|
|
1219
|
+
pkg_info: list[tuple] = []
|
|
1220
|
+
in_pods = False
|
|
1221
|
+
for line in file_content.splitlines():
|
|
1222
|
+
if line.strip() == "PODS:":
|
|
1223
|
+
in_pods = True
|
|
1224
|
+
continue
|
|
1225
|
+
if in_pods:
|
|
1226
|
+
if line and not line.startswith(" "):
|
|
1227
|
+
in_pods = False
|
|
1228
|
+
continue
|
|
1229
|
+
m = re.match(r"^\s*-\s+([A-Za-z0-9_\-]+)\s+\(([0-9.]+)\)", line)
|
|
1230
|
+
if m:
|
|
1231
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1232
|
+
return pkg_info
|
|
1233
|
+
|
|
1234
|
+
@staticmethod
|
|
1235
|
+
def _parse_cartfile(file_content: str) -> list[tuple]:
|
|
1236
|
+
"""Parse Cartfile."""
|
|
1237
|
+
pkg_info: list[tuple] = []
|
|
1238
|
+
for line in file_content.splitlines():
|
|
1239
|
+
m = re.match(r'^\s*github\s+"([^/]+/[^"]+)"\s+[~=]?>=?\s*([0-9.]+)', line)
|
|
1240
|
+
if m:
|
|
1241
|
+
pkg_info.append((f"github.com/{m.group(1)}", m.group(2), None))
|
|
1242
|
+
continue
|
|
1243
|
+
m2 = re.match(r'^\s*git\s+"([^"]+)"\s+[~=]?>=?\s*([0-9.]+)', line)
|
|
1244
|
+
if m2:
|
|
1245
|
+
repo_name = m2.group(1).split("/")[-1].replace(".git", "")
|
|
1246
|
+
pkg_info.append((repo_name, m2.group(2), None))
|
|
1247
|
+
return pkg_info
|
|
1248
|
+
|
|
1249
|
+
@staticmethod
|
|
1250
|
+
def _parse_cartfile_resolved(file_content: str) -> list[tuple]:
|
|
1251
|
+
"""Parse Cartfile.resolved."""
|
|
1252
|
+
pkg_info: list[tuple] = []
|
|
1253
|
+
for line in file_content.splitlines():
|
|
1254
|
+
m = re.match(r'^\s*github\s+"([^/]+/[^"]+)"\s+"([0-9.]+)"', line)
|
|
1255
|
+
if m:
|
|
1256
|
+
pkg_info.append((f"github.com/{m.group(1)}", m.group(2), None))
|
|
1257
|
+
continue
|
|
1258
|
+
m2 = re.match(r'^\s*git\s+"([^"]+)"\s+"([0-9.]+)"', line)
|
|
1259
|
+
if m2:
|
|
1260
|
+
repo_name = m2.group(1).split("/")[-1].replace(".git", "")
|
|
1261
|
+
pkg_info.append((repo_name, m2.group(2), None))
|
|
1262
|
+
return pkg_info
|
|
1263
|
+
|
|
1264
|
+
# --- Haskell ---
|
|
1265
|
+
|
|
1266
|
+
@staticmethod
|
|
1267
|
+
def _parse_stack_yaml(file_content: str) -> list[tuple]:
|
|
1268
|
+
"""Parse stack.yaml file."""
|
|
1269
|
+
pkg_info: list[tuple] = []
|
|
1270
|
+
in_deps = False
|
|
1271
|
+
for line in file_content.splitlines():
|
|
1272
|
+
if line.strip().startswith("extra-deps:"):
|
|
1273
|
+
in_deps = True
|
|
1274
|
+
continue
|
|
1275
|
+
if in_deps:
|
|
1276
|
+
if line and not line.startswith(" ") and not line.startswith("-"):
|
|
1277
|
+
in_deps = False
|
|
1278
|
+
continue
|
|
1279
|
+
m = re.match(r"^\s*-\s*([A-Za-z0-9_\-]+)-([0-9][0-9.]*)", line)
|
|
1280
|
+
if m:
|
|
1281
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1282
|
+
return pkg_info
|
|
1283
|
+
|
|
1284
|
+
@staticmethod
|
|
1285
|
+
def _parse_cabal(file_content: str) -> list[tuple]:
|
|
1286
|
+
"""Parse .cabal file."""
|
|
1287
|
+
pkg_info: list[tuple] = []
|
|
1288
|
+
in_deps = False
|
|
1289
|
+
for line in file_content.splitlines():
|
|
1290
|
+
if line.strip().startswith("build-depends:"):
|
|
1291
|
+
in_deps = True
|
|
1292
|
+
deps_str = line.split(":", 1)[1] if ":" in line else ""
|
|
1293
|
+
for dep in deps_str.split(","):
|
|
1294
|
+
m = re.match(r"\s*([A-Za-z0-9_\-]+)\s*>=?\s*([0-9][0-9.]*)", dep)
|
|
1295
|
+
if m:
|
|
1296
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1297
|
+
continue
|
|
1298
|
+
if in_deps:
|
|
1299
|
+
if line and not line.startswith(" "):
|
|
1300
|
+
in_deps = False
|
|
1301
|
+
continue
|
|
1302
|
+
for dep in line.split(","):
|
|
1303
|
+
m = re.match(r"\s*([A-Za-z0-9_\-]+)\s*>=?\s*([0-9][0-9.]*)", dep)
|
|
1304
|
+
if m:
|
|
1305
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1306
|
+
return pkg_info
|
|
1307
|
+
|
|
1308
|
+
# --- Julia ---
|
|
1309
|
+
|
|
1310
|
+
@staticmethod
|
|
1311
|
+
def _parse_julia_project_toml(file_content: str) -> list[tuple]:
|
|
1312
|
+
"""Parse Julia Project.toml file."""
|
|
1313
|
+
pkg_info: list[tuple] = []
|
|
1314
|
+
in_deps = False
|
|
1315
|
+
for line in file_content.splitlines():
|
|
1316
|
+
if line.strip() == "[deps]":
|
|
1317
|
+
in_deps = True
|
|
1318
|
+
continue
|
|
1319
|
+
if in_deps:
|
|
1320
|
+
if line.startswith("["):
|
|
1321
|
+
break
|
|
1322
|
+
m = re.match(r"^([A-Za-z0-9_]+)\s*=", line)
|
|
1323
|
+
if m:
|
|
1324
|
+
pkg_info.append((m.group(1), None, None))
|
|
1325
|
+
return pkg_info
|
|
1326
|
+
|
|
1327
|
+
@staticmethod
|
|
1328
|
+
def _parse_julia_manifest_toml(file_content: str) -> list[tuple]:
|
|
1329
|
+
"""Parse Julia Manifest.toml file."""
|
|
1330
|
+
pkg_info: list[tuple] = []
|
|
1331
|
+
current_package = None
|
|
1332
|
+
for line in file_content.splitlines():
|
|
1333
|
+
pkg_match = re.match(r"^\[\[(?:deps\.)?([A-Za-z0-9_]+)\]\]", line)
|
|
1334
|
+
if pkg_match:
|
|
1335
|
+
current_package = pkg_match.group(1)
|
|
1336
|
+
continue
|
|
1337
|
+
if current_package:
|
|
1338
|
+
ver_match = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
|
1339
|
+
if ver_match:
|
|
1340
|
+
pkg_info.append((current_package, ver_match.group(1), None))
|
|
1341
|
+
current_package = None
|
|
1342
|
+
return pkg_info
|
|
1343
|
+
|
|
1344
|
+
# --- C++ ---
|
|
1345
|
+
|
|
1346
|
+
@staticmethod
|
|
1347
|
+
def _parse_vcpkg_json(file_content: str) -> list[tuple]:
|
|
1348
|
+
"""Parse vcpkg.json file."""
|
|
1349
|
+
pkg_info: list[tuple] = []
|
|
1350
|
+
try:
|
|
1351
|
+
vcpkg_data = json.loads(file_content)
|
|
1352
|
+
deps = vcpkg_data.get("dependencies", [])
|
|
1353
|
+
for dep in deps:
|
|
1354
|
+
if isinstance(dep, str):
|
|
1355
|
+
pkg_info.append((dep, None, None))
|
|
1356
|
+
elif isinstance(dep, dict):
|
|
1357
|
+
name = dep.get("name")
|
|
1358
|
+
version = dep.get("version>=") or dep.get("version") or dep.get("version-string")
|
|
1359
|
+
if name:
|
|
1360
|
+
pkg_info.append((name, version, None))
|
|
1361
|
+
except Exception:
|
|
1362
|
+
pass
|
|
1363
|
+
return pkg_info
|
|
1364
|
+
|
|
1365
|
+
@staticmethod
|
|
1366
|
+
def _parse_conanfile_txt(file_content: str) -> list[tuple]:
|
|
1367
|
+
"""Parse conanfile.txt file."""
|
|
1368
|
+
pkg_info: list[tuple] = []
|
|
1369
|
+
in_requires = False
|
|
1370
|
+
for line in file_content.splitlines():
|
|
1371
|
+
if line.strip() == "[requires]":
|
|
1372
|
+
in_requires = True
|
|
1373
|
+
continue
|
|
1374
|
+
if in_requires:
|
|
1375
|
+
if line.startswith("["):
|
|
1376
|
+
break
|
|
1377
|
+
m = re.match(r"^\s*([A-Za-z0-9_\-]+)/([0-9][0-9.\-]*)(?:@[^\s]*)?", line)
|
|
1378
|
+
if m:
|
|
1379
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1380
|
+
return pkg_info
|
|
1381
|
+
|
|
1382
|
+
@staticmethod
|
|
1383
|
+
def _parse_conanfile_py(file_content: str) -> list[tuple]:
|
|
1384
|
+
"""Parse conanfile.py file."""
|
|
1385
|
+
pkg_info: list[tuple] = []
|
|
1386
|
+
for line in file_content.splitlines():
|
|
1387
|
+
if "requires" in line and "=" in line:
|
|
1388
|
+
for m in re.finditer(r'["\']([A-Za-z0-9_\-]+)/([0-9][0-9.\-]*)["\']', line):
|
|
1389
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1390
|
+
return pkg_info
|
|
1391
|
+
|
|
1392
|
+
@staticmethod
|
|
1393
|
+
def _parse_cmake_lists(file_content: str) -> list[tuple]:
|
|
1394
|
+
"""Parse CMakeLists.txt file."""
|
|
1395
|
+
pkg_info: list[tuple] = []
|
|
1396
|
+
for line in file_content.splitlines():
|
|
1397
|
+
m = re.match(
|
|
1398
|
+
r"^\s*find_package\s*\(\s*([A-Za-z0-9_]+)(?:\s+([0-9][0-9.]*))?",
|
|
1399
|
+
line,
|
|
1400
|
+
re.IGNORECASE,
|
|
1401
|
+
)
|
|
1402
|
+
if m:
|
|
1403
|
+
pkg_info.append((m.group(1), m.group(2) if m.group(2) else None, None))
|
|
1404
|
+
return pkg_info
|
|
1405
|
+
|
|
1406
|
+
# --- Deno ---
|
|
1407
|
+
|
|
1408
|
+
@staticmethod
|
|
1409
|
+
def _parse_deno_json(file_content: str) -> list[tuple]:
|
|
1410
|
+
"""Parse deno.json or deno.jsonc file."""
|
|
1411
|
+
pkg_info: list[tuple] = []
|
|
1412
|
+
try:
|
|
1413
|
+
content_to_parse = file_content
|
|
1414
|
+
if file_content.startswith("//") or "/*" in file_content:
|
|
1415
|
+
content_to_parse = re.sub(r"//.*$", "", file_content, flags=re.MULTILINE)
|
|
1416
|
+
content_to_parse = re.sub(r"/\*.*?\*/", "", content_to_parse, flags=re.DOTALL)
|
|
1417
|
+
deno_data = json.loads(content_to_parse)
|
|
1418
|
+
imports = deno_data.get("imports", {})
|
|
1419
|
+
for _alias, url in imports.items():
|
|
1420
|
+
if url.startswith("npm:"):
|
|
1421
|
+
pkg_with_ver = url[4:]
|
|
1422
|
+
if "@" in pkg_with_ver:
|
|
1423
|
+
if pkg_with_ver.startswith("@"):
|
|
1424
|
+
parts = pkg_with_ver.split("@")
|
|
1425
|
+
if len(parts) >= 3:
|
|
1426
|
+
pkg_info.append((f"@{parts[1]}", parts[2], None))
|
|
1427
|
+
else:
|
|
1428
|
+
parts = pkg_with_ver.split("@")
|
|
1429
|
+
pkg_info.append((parts[0], parts[1] if len(parts) > 1 else None, None))
|
|
1430
|
+
elif "esm.sh" in url:
|
|
1431
|
+
m = re.search(r"esm\.sh/([^@/]+)@([0-9][0-9.\-]*)", url)
|
|
1432
|
+
if m:
|
|
1433
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1434
|
+
except Exception:
|
|
1435
|
+
pass
|
|
1436
|
+
return pkg_info
|
|
1437
|
+
|
|
1438
|
+
@staticmethod
|
|
1439
|
+
def _parse_deno_lock(file_content: str) -> list[tuple]:
|
|
1440
|
+
"""Parse deno.lock file."""
|
|
1441
|
+
pkg_info: list[tuple] = []
|
|
1442
|
+
try:
|
|
1443
|
+
lock_data = json.loads(file_content)
|
|
1444
|
+
npm_section = lock_data.get("npm", {})
|
|
1445
|
+
specifiers = npm_section.get("specifiers", {})
|
|
1446
|
+
for _alias, full_spec in specifiers.items():
|
|
1447
|
+
if "@" in full_spec:
|
|
1448
|
+
parts = full_spec.split("@")
|
|
1449
|
+
if len(parts) >= 2:
|
|
1450
|
+
version = parts[-1]
|
|
1451
|
+
pkg_name = "@".join(parts[:-1])
|
|
1452
|
+
pkg_info.append((pkg_name, version, None))
|
|
1453
|
+
remote_section = lock_data.get("remote", {})
|
|
1454
|
+
std_versions: set[str] = set()
|
|
1455
|
+
for url in remote_section.keys():
|
|
1456
|
+
m = re.search(r"deno\.land/std@([0-9][0-9.\-]*)", url)
|
|
1457
|
+
if m:
|
|
1458
|
+
std_versions.add(m.group(1))
|
|
1459
|
+
for ver in std_versions:
|
|
1460
|
+
pkg_info.append(("deno_std", ver, None))
|
|
1461
|
+
except Exception:
|
|
1462
|
+
pass
|
|
1463
|
+
return pkg_info
|
|
1464
|
+
|
|
1465
|
+
# --- Perl ---
|
|
1466
|
+
|
|
1467
|
+
@staticmethod
|
|
1468
|
+
def _parse_cpanfile(file_content: str) -> list[tuple]:
|
|
1469
|
+
"""Parse cpanfile."""
|
|
1470
|
+
pkg_info: list[tuple] = []
|
|
1471
|
+
cpan_pattern = re.compile(
|
|
1472
|
+
r"""^\s*(?:on\s+['"][^'"]+['"]\s+)?"""
|
|
1473
|
+
r"""(requires|recommends|suggests|feature|test_requires)\s+"""
|
|
1474
|
+
r"""['"]([A-Za-z0-9_:]+)['"]"""
|
|
1475
|
+
r"""(?:\s*(?:=>|,)\s*['"]([^'"]+)['"])?""",
|
|
1476
|
+
re.MULTILINE,
|
|
1477
|
+
)
|
|
1478
|
+
for match in cpan_pattern.finditer(file_content):
|
|
1479
|
+
pkg_info.append((match.group(2), match.group(3), None))
|
|
1480
|
+
return pkg_info
|
|
1481
|
+
|
|
1482
|
+
@staticmethod
|
|
1483
|
+
def _parse_makefile_pl(file_content: str) -> list[tuple]:
|
|
1484
|
+
"""Parse Makefile.PL or Build.PL."""
|
|
1485
|
+
pkg_info: list[tuple] = []
|
|
1486
|
+
pattern = re.compile(
|
|
1487
|
+
r"""^\s*(requires|recommends|test_requires|build_requires|configure_requires)\s+"""
|
|
1488
|
+
r"""['"]([A-Za-z0-9_:]+)['"]"""
|
|
1489
|
+
r"""\s*=>\s*['"]?([0-9][0-9a-zA-Z._-]*|0)['"]?""",
|
|
1490
|
+
re.MULTILINE,
|
|
1491
|
+
)
|
|
1492
|
+
for match in pattern.finditer(file_content):
|
|
1493
|
+
module = match.group(2)
|
|
1494
|
+
version = match.group(3)
|
|
1495
|
+
version = None if version == "0" else version
|
|
1496
|
+
pkg_info.append((module, version, None))
|
|
1497
|
+
return pkg_info
|
|
1498
|
+
|
|
1499
|
+
# --- R ---
|
|
1500
|
+
|
|
1501
|
+
@staticmethod
|
|
1502
|
+
def _parse_r_description(file_content: str) -> list[tuple]:
|
|
1503
|
+
"""Parse R DESCRIPTION file."""
|
|
1504
|
+
pkg_info: list[tuple] = []
|
|
1505
|
+
in_imports = False
|
|
1506
|
+
in_depends = False
|
|
1507
|
+
for line in file_content.splitlines():
|
|
1508
|
+
if line.startswith("Imports:"):
|
|
1509
|
+
in_imports = True
|
|
1510
|
+
line = line.replace("Imports:", "")
|
|
1511
|
+
elif line.startswith("Depends:"):
|
|
1512
|
+
in_depends = True
|
|
1513
|
+
line = line.replace("Depends:", "")
|
|
1514
|
+
elif line and not line.startswith(" "):
|
|
1515
|
+
in_imports = False
|
|
1516
|
+
in_depends = False
|
|
1517
|
+
continue
|
|
1518
|
+
if in_imports or in_depends:
|
|
1519
|
+
for pkg_str in line.split(","):
|
|
1520
|
+
m = re.match(r"\s*([A-Za-z0-9._]+)\s*(?:\(>=?\s*([0-9][0-9.]*)\))?", pkg_str)
|
|
1521
|
+
if m:
|
|
1522
|
+
pkg_name = m.group(1)
|
|
1523
|
+
if pkg_name.lower() == "r":
|
|
1524
|
+
continue
|
|
1525
|
+
pkg_info.append((pkg_name, m.group(2), None))
|
|
1526
|
+
return pkg_info
|
|
1527
|
+
|
|
1528
|
+
@staticmethod
|
|
1529
|
+
def _parse_renv_lock(file_content: str) -> list[tuple]:
|
|
1530
|
+
"""Parse renv.lock file."""
|
|
1531
|
+
pkg_info: list[tuple] = []
|
|
1532
|
+
try:
|
|
1533
|
+
lock_data = json.loads(file_content)
|
|
1534
|
+
packages = lock_data.get("Packages", {})
|
|
1535
|
+
for pkg_name, pkg_data in packages.items():
|
|
1536
|
+
if isinstance(pkg_data, dict):
|
|
1537
|
+
version = pkg_data.get("Version")
|
|
1538
|
+
if version:
|
|
1539
|
+
pkg_info.append((pkg_name, version, None))
|
|
1540
|
+
except Exception:
|
|
1541
|
+
pass
|
|
1542
|
+
return pkg_info
|
|
1543
|
+
|
|
1544
|
+
@staticmethod
|
|
1545
|
+
def _parse_packrat_lock(file_content: str) -> list[tuple]:
|
|
1546
|
+
"""Parse packrat.lock file."""
|
|
1547
|
+
pkg_info: list[tuple] = []
|
|
1548
|
+
current_package = None
|
|
1549
|
+
for line in file_content.splitlines():
|
|
1550
|
+
pkg_match = re.match(r"^Package:\s*([A-Za-z0-9._]+)", line)
|
|
1551
|
+
if pkg_match:
|
|
1552
|
+
current_package = pkg_match.group(1)
|
|
1553
|
+
continue
|
|
1554
|
+
if current_package:
|
|
1555
|
+
ver_match = re.match(r"^Version:\s*([0-9][0-9.\-]*)", line)
|
|
1556
|
+
if ver_match:
|
|
1557
|
+
pkg_info.append((current_package, ver_match.group(1), None))
|
|
1558
|
+
current_package = None
|
|
1559
|
+
return pkg_info
|
|
1560
|
+
|
|
1561
|
+
# --- Lua ---
|
|
1562
|
+
|
|
1563
|
+
@staticmethod
|
|
1564
|
+
def _parse_rockspec(file_content: str) -> list[tuple]:
|
|
1565
|
+
"""Parse .rockspec file."""
|
|
1566
|
+
pkg_info: list[tuple] = []
|
|
1567
|
+
in_deps = False
|
|
1568
|
+
for line in file_content.splitlines():
|
|
1569
|
+
if "dependencies" in line and "=" in line:
|
|
1570
|
+
in_deps = True
|
|
1571
|
+
continue
|
|
1572
|
+
if in_deps:
|
|
1573
|
+
if line.strip() == "}":
|
|
1574
|
+
break
|
|
1575
|
+
m = re.match(r"""^\s*['"]([A-Za-z0-9_\-]+)\s*[>~]=?\s*([0-9][0-9.]*)['"]""", line)
|
|
1576
|
+
if m:
|
|
1577
|
+
if m.group(1).lower() != "lua":
|
|
1578
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1579
|
+
return pkg_info
|
|
1580
|
+
|
|
1581
|
+
# --- Clojure ---
|
|
1582
|
+
|
|
1583
|
+
@staticmethod
|
|
1584
|
+
def _parse_project_clj(file_content: str) -> list[tuple]:
|
|
1585
|
+
"""Parse project.clj file."""
|
|
1586
|
+
pkg_info: list[tuple] = []
|
|
1587
|
+
for line in file_content.splitlines():
|
|
1588
|
+
m = re.match(r'^\s*\[([A-Za-z0-9_\-./]+)\s+"([^"]+)"\]', line)
|
|
1589
|
+
if m:
|
|
1590
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1591
|
+
return pkg_info
|
|
1592
|
+
|
|
1593
|
+
@staticmethod
|
|
1594
|
+
def _parse_deps_edn(file_content: str) -> list[tuple]:
|
|
1595
|
+
"""Parse deps.edn file."""
|
|
1596
|
+
pkg_info: list[tuple] = []
|
|
1597
|
+
for line in file_content.splitlines():
|
|
1598
|
+
m = re.match(r'^\s*([A-Za-z0-9_\-./]+)\s+\{[^}]*:mvn/version\s+"([^"]+)"', line)
|
|
1599
|
+
if m:
|
|
1600
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1601
|
+
return pkg_info
|
|
1602
|
+
|
|
1603
|
+
# --- OCaml ---
|
|
1604
|
+
|
|
1605
|
+
@staticmethod
|
|
1606
|
+
def _parse_opam(file_content: str) -> list[tuple]:
|
|
1607
|
+
"""Parse .opam or opam file."""
|
|
1608
|
+
pkg_info: list[tuple] = []
|
|
1609
|
+
in_depends = False
|
|
1610
|
+
for line in file_content.splitlines():
|
|
1611
|
+
if line.strip().startswith("depends:"):
|
|
1612
|
+
in_depends = True
|
|
1613
|
+
continue
|
|
1614
|
+
if in_depends:
|
|
1615
|
+
if line.strip() == "]":
|
|
1616
|
+
break
|
|
1617
|
+
m = re.match(r'^\s*"([A-Za-z0-9_\-]+)"\s*\{[^}]*>=?\s*"([^"]+)"', line)
|
|
1618
|
+
if m:
|
|
1619
|
+
if m.group(1) != "ocaml":
|
|
1620
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1621
|
+
return pkg_info
|
|
1622
|
+
|
|
1623
|
+
@staticmethod
|
|
1624
|
+
def _parse_dune_project(file_content: str) -> list[tuple]:
|
|
1625
|
+
"""Parse dune-project file."""
|
|
1626
|
+
pkg_info: list[tuple] = []
|
|
1627
|
+
in_depends = False
|
|
1628
|
+
for line in file_content.splitlines():
|
|
1629
|
+
if line.strip().startswith("(depends"):
|
|
1630
|
+
in_depends = True
|
|
1631
|
+
continue
|
|
1632
|
+
if in_depends:
|
|
1633
|
+
if line.strip() == ")" and not line.strip().startswith("("):
|
|
1634
|
+
break
|
|
1635
|
+
m = re.match(r"^\s*\(([A-Za-z0-9_\-]+)\s*\([^)]*>=?\s*([0-9][0-9.]*)", line)
|
|
1636
|
+
if m:
|
|
1637
|
+
if m.group(1) != "ocaml":
|
|
1638
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1639
|
+
return pkg_info
|
|
1640
|
+
|
|
1641
|
+
# --- Nim ---
|
|
1642
|
+
|
|
1643
|
+
@staticmethod
|
|
1644
|
+
def _parse_nimble(file_content: str) -> list[tuple]:
|
|
1645
|
+
"""Parse .nimble file."""
|
|
1646
|
+
pkg_info: list[tuple] = []
|
|
1647
|
+
for line in file_content.splitlines():
|
|
1648
|
+
m = re.match(r'^\s*requires\s+"([A-Za-z0-9_\-]+)\s*>=?\s*([0-9][0-9.]*)"', line)
|
|
1649
|
+
if m:
|
|
1650
|
+
if m.group(1).lower() != "nim":
|
|
1651
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1652
|
+
return pkg_info
|
|
1653
|
+
|
|
1654
|
+
# --- Zig ---
|
|
1655
|
+
|
|
1656
|
+
@staticmethod
|
|
1657
|
+
def _parse_build_zig_zon(file_content: str) -> list[tuple]:
|
|
1658
|
+
"""Parse build.zig.zon file."""
|
|
1659
|
+
pkg_info: list[tuple] = []
|
|
1660
|
+
in_dependencies = False
|
|
1661
|
+
for line in file_content.splitlines():
|
|
1662
|
+
stripped = line.strip()
|
|
1663
|
+
if ".dependencies" in stripped and "=" in stripped:
|
|
1664
|
+
in_dependencies = True
|
|
1665
|
+
continue
|
|
1666
|
+
if in_dependencies:
|
|
1667
|
+
if stripped in ("},", "}"):
|
|
1668
|
+
in_dependencies = False
|
|
1669
|
+
continue
|
|
1670
|
+
pkg_match = re.match(r"^\.([A-Za-z0-9_\-]+)\s*=", stripped)
|
|
1671
|
+
if pkg_match:
|
|
1672
|
+
pkg_info.append((pkg_match.group(1), None, None))
|
|
1673
|
+
return pkg_info
|
|
1674
|
+
|
|
1675
|
+
# --- Erlang ---
|
|
1676
|
+
|
|
1677
|
+
@staticmethod
|
|
1678
|
+
def _parse_rebar_config(file_content: str) -> list[tuple]:
|
|
1679
|
+
"""Parse rebar.config file."""
|
|
1680
|
+
pkg_info: list[tuple] = []
|
|
1681
|
+
for line in file_content.splitlines():
|
|
1682
|
+
m = re.match(r'^\s*\{([A-Za-z0-9_\-]+),\s*"([0-9][0-9.]*)"', line)
|
|
1683
|
+
if m:
|
|
1684
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1685
|
+
return pkg_info
|
|
1686
|
+
|
|
1687
|
+
@staticmethod
|
|
1688
|
+
def _parse_rebar_lock(file_content: str) -> list[tuple]:
|
|
1689
|
+
"""Parse rebar.lock file."""
|
|
1690
|
+
pkg_info: list[tuple] = []
|
|
1691
|
+
for line in file_content.splitlines():
|
|
1692
|
+
m = re.match(r'^\s*\{([A-Za-z0-9_\-]+),\s*"([0-9][0-9.]*)"', line)
|
|
1693
|
+
if m:
|
|
1694
|
+
pkg_info.append((m.group(1), m.group(2), None))
|
|
1695
|
+
return pkg_info
|
|
1696
|
+
|
|
1697
|
+
# --- GitHub Actions ---
|
|
1698
|
+
|
|
1699
|
+
@staticmethod
|
|
1700
|
+
def _parse_github_actions_workflow(file_content: str) -> list[tuple]:
|
|
1701
|
+
"""Parse GitHub Actions workflow files."""
|
|
1702
|
+
pkg_info: list[tuple] = []
|
|
1703
|
+
for line in file_content.splitlines():
|
|
1704
|
+
m = re.match(r"^\s*uses:\s*([^@\s]+)@([^\s]+)", line)
|
|
1705
|
+
if m:
|
|
1706
|
+
action = m.group(1)
|
|
1707
|
+
version = m.group(2)
|
|
1708
|
+
if re.match(r"^v?\d", version) or version in ("main", "master"):
|
|
1709
|
+
pkg_info.append((action, version, None))
|
|
1710
|
+
return pkg_info
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1714
|
+
# SECTION 2 — Source Import Extractors (ported from ExtensionPackageParser)
|
|
1715
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1716
|
+
|
|
1717
|
+
class SourceImportExtractor:
|
|
1718
|
+
"""
|
|
1719
|
+
Regex-based extraction of external package names from source files,
|
|
1720
|
+
supporting 25+ languages.
|
|
1721
|
+
|
|
1722
|
+
Ported from ``ExtensionPackageParser`` with no Django dependencies.
|
|
1723
|
+
"""
|
|
1724
|
+
|
|
1725
|
+
@staticmethod
|
|
1726
|
+
def parse_packages(extension: str, file_content: str) -> list[str]:
|
|
1727
|
+
"""Route to the correct language-specific parser by extension."""
|
|
1728
|
+
try:
|
|
1729
|
+
extension = extension.lower()
|
|
1730
|
+
parser_map = {
|
|
1731
|
+
".py": SourceImportExtractor._parse_python,
|
|
1732
|
+
".pyx": SourceImportExtractor._parse_python,
|
|
1733
|
+
".pyi": SourceImportExtractor._parse_python,
|
|
1734
|
+
".js": SourceImportExtractor._parse_js_ts,
|
|
1735
|
+
".jsx": SourceImportExtractor._parse_js_ts,
|
|
1736
|
+
".mjs": SourceImportExtractor._parse_js_ts,
|
|
1737
|
+
".cjs": SourceImportExtractor._parse_js_ts,
|
|
1738
|
+
".ts": SourceImportExtractor._parse_js_ts,
|
|
1739
|
+
".tsx": SourceImportExtractor._parse_js_ts,
|
|
1740
|
+
".go": SourceImportExtractor._parse_go,
|
|
1741
|
+
".java": SourceImportExtractor._parse_java,
|
|
1742
|
+
".php": SourceImportExtractor._parse_php,
|
|
1743
|
+
".php5": SourceImportExtractor._parse_php,
|
|
1744
|
+
".phps": SourceImportExtractor._parse_php,
|
|
1745
|
+
".phtml": SourceImportExtractor._parse_php,
|
|
1746
|
+
".rb": SourceImportExtractor._parse_ruby,
|
|
1747
|
+
".rs": SourceImportExtractor._parse_rust,
|
|
1748
|
+
".cs": SourceImportExtractor._parse_csharp,
|
|
1749
|
+
".swift": SourceImportExtractor._parse_swift,
|
|
1750
|
+
".kt": SourceImportExtractor._parse_kotlin,
|
|
1751
|
+
".kts": SourceImportExtractor._parse_kotlin,
|
|
1752
|
+
".scala": SourceImportExtractor._parse_scala,
|
|
1753
|
+
".dart": SourceImportExtractor._parse_dart,
|
|
1754
|
+
".c": SourceImportExtractor._parse_cpp,
|
|
1755
|
+
".cc": SourceImportExtractor._parse_cpp,
|
|
1756
|
+
".cpp": SourceImportExtractor._parse_cpp,
|
|
1757
|
+
".h": SourceImportExtractor._parse_cpp,
|
|
1758
|
+
".hpp": SourceImportExtractor._parse_cpp,
|
|
1759
|
+
".gradle": SourceImportExtractor._parse_gradle,
|
|
1760
|
+
".ex": SourceImportExtractor._parse_elixir,
|
|
1761
|
+
".exs": SourceImportExtractor._parse_elixir,
|
|
1762
|
+
".hs": SourceImportExtractor._parse_haskell,
|
|
1763
|
+
".r": SourceImportExtractor._parse_r,
|
|
1764
|
+
".lua": SourceImportExtractor._parse_lua,
|
|
1765
|
+
".pl": SourceImportExtractor._parse_perl,
|
|
1766
|
+
".pm": SourceImportExtractor._parse_perl,
|
|
1767
|
+
".m": SourceImportExtractor._parse_objc,
|
|
1768
|
+
".mm": SourceImportExtractor._parse_objc,
|
|
1769
|
+
".cmake": SourceImportExtractor._parse_cmake,
|
|
1770
|
+
}
|
|
1771
|
+
parser_fn = parser_map.get(extension)
|
|
1772
|
+
return parser_fn(file_content) if parser_fn else []
|
|
1773
|
+
except Exception:
|
|
1774
|
+
return []
|
|
1775
|
+
|
|
1776
|
+
# --- Language parsers ---
|
|
1777
|
+
|
|
1778
|
+
@staticmethod
|
|
1779
|
+
def _parse_python(content: str) -> list[str]:
|
|
1780
|
+
imports: set[str] = set()
|
|
1781
|
+
for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE):
|
|
1782
|
+
root = match.group(1).split(",")[0].strip().split(".")[0]
|
|
1783
|
+
if root and not root.startswith("."):
|
|
1784
|
+
imports.add(root)
|
|
1785
|
+
for match in re.finditer(r"^\s*from\s+([a-zA-Z0-9_.]+)\s+import", content, re.MULTILINE):
|
|
1786
|
+
root = match.group(1).split(".")[0]
|
|
1787
|
+
if root and not root.startswith("."):
|
|
1788
|
+
imports.add(root)
|
|
1789
|
+
return list(imports)
|
|
1790
|
+
|
|
1791
|
+
@staticmethod
|
|
1792
|
+
def _parse_js_ts(content: str) -> list[str]:
|
|
1793
|
+
imports: set[str] = set()
|
|
1794
|
+
|
|
1795
|
+
def _is_internal(pkg: str) -> bool:
|
|
1796
|
+
if pkg.startswith((".", "/", "#")):
|
|
1797
|
+
return True
|
|
1798
|
+
if pkg.startswith("@/") or pkg.startswith("~/"):
|
|
1799
|
+
return True
|
|
1800
|
+
if "://" in pkg:
|
|
1801
|
+
return True
|
|
1802
|
+
return False
|
|
1803
|
+
|
|
1804
|
+
def _root_pkg(pkg: str) -> str | None:
|
|
1805
|
+
if pkg.startswith("@"):
|
|
1806
|
+
parts = pkg.split("/")
|
|
1807
|
+
return f"{parts[0]}/{parts[1]}" if len(parts) >= 2 else None
|
|
1808
|
+
return pkg.split("/")[0]
|
|
1809
|
+
|
|
1810
|
+
for match in re.finditer(r'''require\(["']([^"']+)["']\)''', content):
|
|
1811
|
+
pkg = match.group(1)
|
|
1812
|
+
if not _is_internal(pkg):
|
|
1813
|
+
root = _root_pkg(pkg)
|
|
1814
|
+
if root:
|
|
1815
|
+
imports.add(root)
|
|
1816
|
+
|
|
1817
|
+
for match in re.finditer(r'''import[^;]*?from\s+["']([^"']+)["']''', content):
|
|
1818
|
+
pkg = match.group(1)
|
|
1819
|
+
if not _is_internal(pkg):
|
|
1820
|
+
root = _root_pkg(pkg)
|
|
1821
|
+
if root:
|
|
1822
|
+
imports.add(root)
|
|
1823
|
+
|
|
1824
|
+
for match in re.finditer(r'''import\s+["']([^"']+)["']''', content):
|
|
1825
|
+
pkg = match.group(1)
|
|
1826
|
+
if not _is_internal(pkg):
|
|
1827
|
+
root = _root_pkg(pkg)
|
|
1828
|
+
if root:
|
|
1829
|
+
imports.add(root)
|
|
1830
|
+
|
|
1831
|
+
return list(imports)
|
|
1832
|
+
|
|
1833
|
+
@staticmethod
|
|
1834
|
+
def _root_go_path(path: str) -> str:
|
|
1835
|
+
parts = path.split("/")
|
|
1836
|
+
return "/".join(parts[:3] if len(parts) >= 3 else parts)
|
|
1837
|
+
|
|
1838
|
+
@staticmethod
|
|
1839
|
+
def _parse_go(content: str) -> list[str]:
|
|
1840
|
+
imports: set[str] = set()
|
|
1841
|
+
single_re = re.compile(
|
|
1842
|
+
r'^\s*import\s+(?:[A-Za-z_][A-Za-z0-9_]*\s+)?"([^"]+)"',
|
|
1843
|
+
re.MULTILINE,
|
|
1844
|
+
)
|
|
1845
|
+
for match in single_re.finditer(content):
|
|
1846
|
+
path = match.group(1)
|
|
1847
|
+
if "/" in path:
|
|
1848
|
+
imports.add(SourceImportExtractor._root_go_path(path))
|
|
1849
|
+
else:
|
|
1850
|
+
imports.add(path)
|
|
1851
|
+
|
|
1852
|
+
block_match = re.search(r"^\s*import\s*\((.*?)\)", content, re.DOTALL | re.MULTILINE)
|
|
1853
|
+
if block_match:
|
|
1854
|
+
for raw_line in block_match.group(1).split("\n"):
|
|
1855
|
+
line = raw_line.strip()
|
|
1856
|
+
if not line:
|
|
1857
|
+
continue
|
|
1858
|
+
m = re.match(r'(?:[A-Za-z_][A-Za-z0-9_]*\s+)?"([^"]+)"', line)
|
|
1859
|
+
if not m:
|
|
1860
|
+
continue
|
|
1861
|
+
path = m.group(1)
|
|
1862
|
+
if path.startswith((".", "/")):
|
|
1863
|
+
continue
|
|
1864
|
+
if "/" in path:
|
|
1865
|
+
imports.add(SourceImportExtractor._root_go_path(path))
|
|
1866
|
+
else:
|
|
1867
|
+
imports.add(path)
|
|
1868
|
+
return list(imports)
|
|
1869
|
+
|
|
1870
|
+
@staticmethod
|
|
1871
|
+
def _parse_java(content: str) -> list[str]:
|
|
1872
|
+
imports: set[str] = set()
|
|
1873
|
+
for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+);", content, re.MULTILINE):
|
|
1874
|
+
root = ".".join(match.group(1).split(".")[:3])
|
|
1875
|
+
imports.add(root)
|
|
1876
|
+
return list(imports)
|
|
1877
|
+
|
|
1878
|
+
@staticmethod
|
|
1879
|
+
def _parse_php(content: str) -> list[str]:
|
|
1880
|
+
imports: set[str] = set()
|
|
1881
|
+
for match in re.finditer(r"^\s*use\s+([A-Za-z0-9_\\\\]+)", content, re.MULTILINE):
|
|
1882
|
+
vendor = match.group(1).strip("\\").split("\\")[0]
|
|
1883
|
+
if vendor:
|
|
1884
|
+
imports.add(vendor)
|
|
1885
|
+
for match in re.finditer(
|
|
1886
|
+
r"""\b(?:require|include)(?:_once)?\s*\(\s*["']([^"']+)["']""", content
|
|
1887
|
+
):
|
|
1888
|
+
path = match.group(1)
|
|
1889
|
+
if not path.startswith((".", "/")) and "/" in path:
|
|
1890
|
+
imports.add(path.split("/")[0])
|
|
1891
|
+
return list(imports)
|
|
1892
|
+
|
|
1893
|
+
@staticmethod
|
|
1894
|
+
def _parse_ruby(content: str) -> list[str]:
|
|
1895
|
+
imports: set[str] = set()
|
|
1896
|
+
for match in re.finditer(r"""^\s*require\s+["']([^"']+)["']""", content, re.MULTILINE):
|
|
1897
|
+
gem = match.group(1)
|
|
1898
|
+
if not gem.startswith("."):
|
|
1899
|
+
imports.add(gem.split("/")[-1])
|
|
1900
|
+
return list(imports)
|
|
1901
|
+
|
|
1902
|
+
@staticmethod
|
|
1903
|
+
def _parse_rust(content: str) -> list[str]:
|
|
1904
|
+
imports: set[str] = set()
|
|
1905
|
+
for match in re.finditer(r"^\s*extern\s+crate\s+([A-Za-z0-9_]+);", content, re.MULTILINE):
|
|
1906
|
+
imports.add(match.group(1))
|
|
1907
|
+
for match in re.finditer(r"^\s*use\s+([A-Za-z0-9_]+)::", content, re.MULTILINE):
|
|
1908
|
+
imports.add(match.group(1))
|
|
1909
|
+
return list(imports)
|
|
1910
|
+
|
|
1911
|
+
@staticmethod
|
|
1912
|
+
def _parse_csharp(content: str) -> list[str]:
|
|
1913
|
+
imports: set[str] = set()
|
|
1914
|
+
for match in re.finditer(r"^\s*using\s+([A-Za-z0-9_.]+);", content, re.MULTILINE):
|
|
1915
|
+
root = match.group(1).split(".")[0]
|
|
1916
|
+
if root:
|
|
1917
|
+
imports.add(root)
|
|
1918
|
+
return list(imports)
|
|
1919
|
+
|
|
1920
|
+
@staticmethod
|
|
1921
|
+
def _parse_swift(content: str) -> list[str]:
|
|
1922
|
+
imports: set[str] = set()
|
|
1923
|
+
for match in re.finditer(r"^\s*import\s+([A-Za-z0-9_]+)", content, re.MULTILINE):
|
|
1924
|
+
imports.add(match.group(1))
|
|
1925
|
+
return list(imports)
|
|
1926
|
+
|
|
1927
|
+
@staticmethod
|
|
1928
|
+
def _parse_kotlin(content: str) -> list[str]:
|
|
1929
|
+
imports: set[str] = set()
|
|
1930
|
+
for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE):
|
|
1931
|
+
root = ".".join(match.group(1).split(".")[:3])
|
|
1932
|
+
imports.add(root)
|
|
1933
|
+
return list(imports)
|
|
1934
|
+
|
|
1935
|
+
@staticmethod
|
|
1936
|
+
def _parse_scala(content: str) -> list[str]:
|
|
1937
|
+
imports: set[str] = set()
|
|
1938
|
+
for match in re.finditer(r"^\s*import\s+([a-zA-Z0-9_.]+)", content, re.MULTILINE):
|
|
1939
|
+
root = ".".join(match.group(1).split(".")[:3])
|
|
1940
|
+
imports.add(root)
|
|
1941
|
+
return list(imports)
|
|
1942
|
+
|
|
1943
|
+
@staticmethod
|
|
1944
|
+
def _parse_dart(content: str) -> list[str]:
|
|
1945
|
+
imports: set[str] = set()
|
|
1946
|
+
for match in re.finditer(r"""\bimport\s+["']package:([^/'"]+)""", content):
|
|
1947
|
+
imports.add(match.group(1))
|
|
1948
|
+
return list(imports)
|
|
1949
|
+
|
|
1950
|
+
@staticmethod
|
|
1951
|
+
def _parse_cpp(content: str) -> list[str]:
|
|
1952
|
+
imports: set[str] = set()
|
|
1953
|
+
for match in re.finditer(r'^\s*#include\s+[<"]([^">]+)[">]', content, re.MULTILINE):
|
|
1954
|
+
header = match.group(1)
|
|
1955
|
+
if "/" in header:
|
|
1956
|
+
imports.add(header.split("/")[0])
|
|
1957
|
+
else:
|
|
1958
|
+
imports.add(header.split(".")[0])
|
|
1959
|
+
return list(imports)
|
|
1960
|
+
|
|
1961
|
+
@staticmethod
|
|
1962
|
+
def _parse_gradle(content: str) -> list[str]:
|
|
1963
|
+
imports: set[str] = set()
|
|
1964
|
+
for match in re.finditer(
|
|
1965
|
+
r"""\b(?:implementation|api|compile|compileOnly|testImplementation|runtimeOnly|kapt)\s+['"]([^:'"]+):([^:'"]+):[^'"]+['"]""",
|
|
1966
|
+
content,
|
|
1967
|
+
):
|
|
1968
|
+
imports.add(match.group(2))
|
|
1969
|
+
return list(imports)
|
|
1970
|
+
|
|
1971
|
+
@staticmethod
|
|
1972
|
+
def _parse_elixir(content: str) -> list[str]:
|
|
1973
|
+
imports: set[str] = set()
|
|
1974
|
+
for match in re.finditer(r"\{\s*:(\w+)\s*,", content):
|
|
1975
|
+
imports.add(match.group(1))
|
|
1976
|
+
return list(imports)
|
|
1977
|
+
|
|
1978
|
+
@staticmethod
|
|
1979
|
+
def _parse_haskell(content: str) -> list[str]:
|
|
1980
|
+
imports: set[str] = set()
|
|
1981
|
+
for match in re.finditer(
|
|
1982
|
+
r"^\s*import\s+(?:qualified\s+)?([A-Z][A-Za-z0-9_.]+)", content, re.MULTILINE
|
|
1983
|
+
):
|
|
1984
|
+
imports.add(match.group(1).split(".")[0])
|
|
1985
|
+
return list(imports)
|
|
1986
|
+
|
|
1987
|
+
@staticmethod
|
|
1988
|
+
def _parse_r(content: str) -> list[str]:
|
|
1989
|
+
imports: set[str] = set()
|
|
1990
|
+
for match in re.finditer(
|
|
1991
|
+
r"""\b(?:library|require)\s*\(\s*["']?([A-Za-z0-9_.]+)["']?\s*\)""", content
|
|
1992
|
+
):
|
|
1993
|
+
imports.add(match.group(1))
|
|
1994
|
+
return list(imports)
|
|
1995
|
+
|
|
1996
|
+
@staticmethod
|
|
1997
|
+
def _parse_lua(content: str) -> list[str]:
|
|
1998
|
+
imports: set[str] = set()
|
|
1999
|
+
for match in re.finditer(r"""\brequire\s*\(?\s*["']([^"']+)["']""", content):
|
|
2000
|
+
module = match.group(1)
|
|
2001
|
+
if not module.startswith((".", "/")):
|
|
2002
|
+
imports.add(module.split(".")[0])
|
|
2003
|
+
return list(imports)
|
|
2004
|
+
|
|
2005
|
+
@staticmethod
|
|
2006
|
+
def _parse_perl(content: str) -> list[str]:
|
|
2007
|
+
imports: set[str] = set()
|
|
2008
|
+
for match in re.finditer(r"^\s*use\s+([A-Za-z0-9_:]+)", content, re.MULTILINE):
|
|
2009
|
+
imports.add(match.group(1))
|
|
2010
|
+
return list(imports)
|
|
2011
|
+
|
|
2012
|
+
@staticmethod
|
|
2013
|
+
def _parse_objc(content: str) -> list[str]:
|
|
2014
|
+
imports: set[str] = set()
|
|
2015
|
+
for match in re.finditer(r'^\s*#import\s+[<"]([^">]+)[">]', content, re.MULTILINE):
|
|
2016
|
+
header = match.group(1)
|
|
2017
|
+
if "/" in header:
|
|
2018
|
+
imports.add(header.split("/")[0])
|
|
2019
|
+
else:
|
|
2020
|
+
imports.add(header.split(".")[0])
|
|
2021
|
+
return list(imports)
|
|
2022
|
+
|
|
2023
|
+
@staticmethod
|
|
2024
|
+
def _parse_cmake(content: str) -> list[str]:
|
|
2025
|
+
imports: set[str] = set()
|
|
2026
|
+
for match in re.finditer(r"\bfind_package\s*\(\s*([A-Za-z0-9_]+)", content):
|
|
2027
|
+
imports.add(match.group(1))
|
|
2028
|
+
return list(imports)
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2032
|
+
# SECTION 3 — Vulnerability Scanner (ported from vulnerability_scanner.py)
|
|
2033
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2034
|
+
|
|
2035
|
+
# Default empty-result template
|
|
2036
|
+
_EMPTY_VULN: Dict[str, object] = {
|
|
2037
|
+
"vulnerability_count": 0,
|
|
2038
|
+
"critical_vulnerabilities": 0,
|
|
2039
|
+
"high_vulnerabilities": 0,
|
|
2040
|
+
"medium_vulnerabilities": 0,
|
|
2041
|
+
"low_vulnerabilities": 0,
|
|
2042
|
+
"vulnerability_summary": "No known vulnerabilities",
|
|
2043
|
+
"latest_vulnerability_date": None,
|
|
2044
|
+
"vulnerability_ids": "",
|
|
2045
|
+
"vulnerability_details": "",
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
def _process_osv_response(response_data: dict) -> dict:
|
|
2050
|
+
"""Parse an OSV single-package response into a structured vuln summary."""
|
|
2051
|
+
if not response_data or "vulns" not in response_data:
|
|
2052
|
+
return dict(_EMPTY_VULN)
|
|
2053
|
+
|
|
2054
|
+
vulns = response_data["vulns"]
|
|
2055
|
+
vulnerability_count = len(vulns)
|
|
2056
|
+
critical_count = high_count = medium_count = low_count = 0
|
|
2057
|
+
vuln_ids: list[str] = []
|
|
2058
|
+
vuln_details: list[str] = []
|
|
2059
|
+
latest_date: datetime | None = None
|
|
2060
|
+
|
|
2061
|
+
for vuln in vulns:
|
|
2062
|
+
vuln_id = vuln.get("id", "")
|
|
2063
|
+
vuln_ids.append(vuln_id)
|
|
2064
|
+
|
|
2065
|
+
summary = vuln.get("summary", "")
|
|
2066
|
+
if summary:
|
|
2067
|
+
vuln_details.append(f"{vuln_id}: {summary[:200]}")
|
|
2068
|
+
|
|
2069
|
+
published = vuln.get("published")
|
|
2070
|
+
if published:
|
|
2071
|
+
try:
|
|
2072
|
+
vuln_date = datetime.fromisoformat(published.replace("Z", "+00:00"))
|
|
2073
|
+
if latest_date is None or vuln_date > latest_date:
|
|
2074
|
+
latest_date = vuln_date
|
|
2075
|
+
except Exception:
|
|
2076
|
+
pass
|
|
2077
|
+
|
|
2078
|
+
severity = None
|
|
2079
|
+
if "severity" in vuln:
|
|
2080
|
+
for sev_entry in vuln["severity"]:
|
|
2081
|
+
if sev_entry.get("type") == "CVSS_V3":
|
|
2082
|
+
score = sev_entry.get("score")
|
|
2083
|
+
if score:
|
|
2084
|
+
try:
|
|
2085
|
+
cvss = float(score)
|
|
2086
|
+
if cvss >= 9.0:
|
|
2087
|
+
severity = "CRITICAL"
|
|
2088
|
+
elif cvss >= 7.0:
|
|
2089
|
+
severity = "HIGH"
|
|
2090
|
+
elif cvss >= 4.0:
|
|
2091
|
+
severity = "MEDIUM"
|
|
2092
|
+
else:
|
|
2093
|
+
severity = "LOW"
|
|
2094
|
+
break
|
|
2095
|
+
except ValueError:
|
|
2096
|
+
pass
|
|
2097
|
+
|
|
2098
|
+
if not severity and "database_specific" in vuln:
|
|
2099
|
+
db_sev = vuln["database_specific"].get("severity", "").upper()
|
|
2100
|
+
if db_sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
2101
|
+
severity = db_sev
|
|
2102
|
+
|
|
2103
|
+
if severity == "CRITICAL":
|
|
2104
|
+
critical_count += 1
|
|
2105
|
+
elif severity == "HIGH":
|
|
2106
|
+
high_count += 1
|
|
2107
|
+
elif severity == "MEDIUM":
|
|
2108
|
+
medium_count += 1
|
|
2109
|
+
elif severity == "LOW":
|
|
2110
|
+
low_count += 1
|
|
2111
|
+
else:
|
|
2112
|
+
medium_count += 1
|
|
2113
|
+
|
|
2114
|
+
if critical_count > 0:
|
|
2115
|
+
summary_str = f"{critical_count} critical, {high_count} high severity vulnerabilities found"
|
|
2116
|
+
elif high_count > 0:
|
|
2117
|
+
summary_str = f"{high_count} high, {medium_count} medium severity vulnerabilities found"
|
|
2118
|
+
elif medium_count > 0:
|
|
2119
|
+
summary_str = f"{medium_count} medium, {low_count} low severity vulnerabilities found"
|
|
2120
|
+
elif low_count > 0:
|
|
2121
|
+
summary_str = f"{low_count} low severity vulnerabilities found"
|
|
2122
|
+
else:
|
|
2123
|
+
summary_str = f"{vulnerability_count} vulnerabilities found"
|
|
2124
|
+
|
|
2125
|
+
return {
|
|
2126
|
+
"vulnerability_count": vulnerability_count,
|
|
2127
|
+
"critical_vulnerabilities": critical_count,
|
|
2128
|
+
"high_vulnerabilities": high_count,
|
|
2129
|
+
"medium_vulnerabilities": medium_count,
|
|
2130
|
+
"low_vulnerabilities": low_count,
|
|
2131
|
+
"vulnerability_summary": summary_str,
|
|
2132
|
+
"latest_vulnerability_date": latest_date.isoformat() if latest_date else None,
|
|
2133
|
+
"vulnerability_ids": ", ".join(vuln_ids[:10]) + ("..." if len(vuln_ids) > 10 else ""),
|
|
2134
|
+
"vulnerability_details": " | ".join(vuln_details[:5]) + ("..." if len(vuln_details) > 5 else ""),
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
class VulnerabilityScanner:
|
|
2139
|
+
"""
|
|
2140
|
+
Query the OSV public API for known vulnerabilities.
|
|
2141
|
+
|
|
2142
|
+
Uses ``httpx`` for HTTP calls (replacing server-side ``requests``).
|
|
2143
|
+
All configuration (URLs, retries, ecosystem mappings) is supplied
|
|
2144
|
+
via the *config* dict fetched from the server.
|
|
2145
|
+
"""
|
|
2146
|
+
|
|
2147
|
+
def __init__(self, config: dict, on_debug: Callable[[str], None] | None = None):
|
|
2148
|
+
self._osv_api_url = config.get("osv_api_url", "https://api.osv.dev/v1/query")
|
|
2149
|
+
self._osv_batch_url = config.get("osv_batch_url", "https://api.osv.dev/v1/querybatch")
|
|
2150
|
+
self._max_retries = config.get("max_retries", 3)
|
|
2151
|
+
self._retry_backoff = config.get("retry_backoff", 1.5)
|
|
2152
|
+
self._ecosystem_map: dict[str, str] = config.get("osv_ecosystem_map", {})
|
|
2153
|
+
self._prefix_to_registry: dict[str, str] = config.get("prefix_to_registry", {})
|
|
2154
|
+
self._debug = on_debug or (lambda _: None)
|
|
2155
|
+
|
|
2156
|
+
def _http_post_json(self, url: str, data: dict) -> dict:
|
|
2157
|
+
"""HTTP POST with retry and exponential back-off."""
|
|
2158
|
+
attempt = 0
|
|
2159
|
+
while attempt < self._max_retries:
|
|
2160
|
+
try:
|
|
2161
|
+
resp = httpx.post(url, json=data, timeout=30.0)
|
|
2162
|
+
if resp.status_code == 200:
|
|
2163
|
+
return resp.json()
|
|
2164
|
+
except Exception as exc:
|
|
2165
|
+
logger.debug("HTTP POST failed for %s (attempt %d): %s", url, attempt + 1, exc)
|
|
2166
|
+
attempt += 1
|
|
2167
|
+
if attempt < self._max_retries:
|
|
2168
|
+
time.sleep(self._retry_backoff * (2 ** (attempt - 1)))
|
|
2169
|
+
return {}
|
|
2170
|
+
|
|
2171
|
+
def _strip_prefix(self, package_name: str) -> tuple[str, str]:
|
|
2172
|
+
"""Remove our internal registry prefix and return (bare_name, registry)."""
|
|
2173
|
+
for prefix, registry in self._prefix_to_registry.items():
|
|
2174
|
+
if package_name.startswith(prefix):
|
|
2175
|
+
return package_name[len(prefix):], registry
|
|
2176
|
+
return package_name, "unknown"
|
|
2177
|
+
|
|
2178
|
+
def scan_batch(self, packages: list[dict]) -> dict[str, dict]:
|
|
2179
|
+
"""
|
|
2180
|
+
Scan a list of packages for vulnerabilities via OSV batch API.
|
|
2181
|
+
|
|
2182
|
+
Each entry in *packages* must have ``prefixed_name`` and optionally
|
|
2183
|
+
``version``. Returns a dict keyed by ``prefixed_name``.
|
|
2184
|
+
"""
|
|
2185
|
+
results: dict[str, dict] = {}
|
|
2186
|
+
if not packages:
|
|
2187
|
+
return results
|
|
2188
|
+
|
|
2189
|
+
# Build batch queries grouped by ecosystem
|
|
2190
|
+
queries: list[dict] = []
|
|
2191
|
+
query_keys: list[str] = []
|
|
2192
|
+
|
|
2193
|
+
for pkg in packages:
|
|
2194
|
+
prefixed = pkg.get("prefixed_name", "")
|
|
2195
|
+
version = pkg.get("version")
|
|
2196
|
+
bare_name, registry = self._strip_prefix(prefixed)
|
|
2197
|
+
ecosystem = self._ecosystem_map.get(registry)
|
|
2198
|
+
|
|
2199
|
+
if not ecosystem:
|
|
2200
|
+
results[prefixed] = dict(_EMPTY_VULN)
|
|
2201
|
+
continue
|
|
2202
|
+
|
|
2203
|
+
query: dict = {"package": {"name": bare_name, "ecosystem": ecosystem}}
|
|
2204
|
+
if version and version.strip() and version != "latest":
|
|
2205
|
+
query["version"] = version.strip()
|
|
2206
|
+
|
|
2207
|
+
queries.append(query)
|
|
2208
|
+
query_keys.append(prefixed)
|
|
2209
|
+
|
|
2210
|
+
if not queries:
|
|
2211
|
+
return results
|
|
2212
|
+
|
|
2213
|
+
# OSV batch API accepts up to 1000 queries per request
|
|
2214
|
+
batch_size = 1000
|
|
2215
|
+
for i in range(0, len(queries), batch_size):
|
|
2216
|
+
batch_queries = queries[i: i + batch_size]
|
|
2217
|
+
batch_keys = query_keys[i: i + batch_size]
|
|
2218
|
+
|
|
2219
|
+
self._debug(f"Querying OSV for {len(batch_queries)} packages (batch {i // batch_size + 1})")
|
|
2220
|
+
|
|
2221
|
+
batch_data = {"queries": batch_queries}
|
|
2222
|
+
resp_data = self._http_post_json(self._osv_batch_url, batch_data)
|
|
2223
|
+
|
|
2224
|
+
if not resp_data or "results" not in resp_data:
|
|
2225
|
+
for key in batch_keys:
|
|
2226
|
+
results[key] = dict(_EMPTY_VULN)
|
|
2227
|
+
results[key]["vulnerability_summary"] = "Batch query failed"
|
|
2228
|
+
continue
|
|
2229
|
+
|
|
2230
|
+
for idx, raw_result in enumerate(resp_data["results"]):
|
|
2231
|
+
key = batch_keys[idx]
|
|
2232
|
+
if raw_result and "vulns" in raw_result:
|
|
2233
|
+
results[key] = _process_osv_response(raw_result)
|
|
2234
|
+
else:
|
|
2235
|
+
results[key] = dict(_EMPTY_VULN)
|
|
2236
|
+
|
|
2237
|
+
# Brief pause between batches to respect rate limits
|
|
2238
|
+
if i + batch_size < len(queries):
|
|
2239
|
+
time.sleep(0.5)
|
|
2240
|
+
|
|
2241
|
+
return results
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2245
|
+
# SECTION 4 — Orchestrator (LocalDependencyScanner)
|
|
2246
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2247
|
+
|
|
2248
|
+
class LocalDependencyScanner:
|
|
2249
|
+
"""
|
|
2250
|
+
High-level scanner that orchestrates manifest parsing, source-import
|
|
2251
|
+
extraction, and OSV vulnerability scanning — all running locally.
|
|
2252
|
+
|
|
2253
|
+
``config`` is fetched from the server's hidden-config endpoint
|
|
2254
|
+
(``GET /api/cli/audit/dependency-config/``) and contains registry
|
|
2255
|
+
mappings, OSV settings, etc.
|
|
2256
|
+
"""
|
|
2257
|
+
|
|
2258
|
+
def __init__(
|
|
2259
|
+
self,
|
|
2260
|
+
config: dict,
|
|
2261
|
+
on_debug: Callable[[str], None] | None = None,
|
|
2262
|
+
on_progress: Callable[[str], None] | None = None,
|
|
2263
|
+
):
|
|
2264
|
+
self._config = config
|
|
2265
|
+
self._registry_prefix_map: dict[str, str] = config.get("registry_prefix_map", {})
|
|
2266
|
+
self._dep_file_prefixes: dict[str, str] = config.get("dependency_file_prefixes", {})
|
|
2267
|
+
self._debug = on_debug or (lambda _: None)
|
|
2268
|
+
self._progress = on_progress or (lambda _: None)
|
|
2269
|
+
self._vuln_scanner = VulnerabilityScanner(
|
|
2270
|
+
config=config.get("vuln_scan", {}),
|
|
2271
|
+
on_debug=on_debug,
|
|
2272
|
+
)
|
|
2273
|
+
|
|
2274
|
+
# ----- Manifest scanning -----
|
|
2275
|
+
|
|
2276
|
+
def scan_manifests(
|
|
2277
|
+
self,
|
|
2278
|
+
scope_dirs: dict[str, str],
|
|
2279
|
+
) -> list[ManifestResult]:
|
|
2280
|
+
"""
|
|
2281
|
+
Walk local directories, find dependency manifest files, and parse them.
|
|
2282
|
+
|
|
2283
|
+
Args:
|
|
2284
|
+
scope_dirs: Mapping of ``repo_name → local_path``.
|
|
2285
|
+
|
|
2286
|
+
Returns:
|
|
2287
|
+
One ``ManifestResult`` per manifest file found.
|
|
2288
|
+
"""
|
|
2289
|
+
results: list[ManifestResult] = []
|
|
2290
|
+
|
|
2291
|
+
for repo_name, local_path in scope_dirs.items():
|
|
2292
|
+
if not local_path or not os.path.isdir(local_path):
|
|
2293
|
+
self._debug(f"Skipping repo '{repo_name}': directory not found")
|
|
2294
|
+
continue
|
|
2295
|
+
|
|
2296
|
+
manifest_paths: list[str] = []
|
|
2297
|
+
for root, _dirs, files in os.walk(local_path):
|
|
2298
|
+
# Skip common non-project directories
|
|
2299
|
+
rel_root = os.path.relpath(root, local_path)
|
|
2300
|
+
skip_dirs = {"node_modules", ".git", "__pycache__", ".tox", "venv", ".venv", "vendor", "dist", "build"}
|
|
2301
|
+
if any(part in skip_dirs for part in rel_root.split(os.sep)):
|
|
2302
|
+
continue
|
|
2303
|
+
for fname in files:
|
|
2304
|
+
abs_path = os.path.join(root, fname)
|
|
2305
|
+
rel_path = os.path.relpath(abs_path, local_path)
|
|
2306
|
+
if ManifestParser.is_dependency_file(rel_path):
|
|
2307
|
+
manifest_paths.append(abs_path)
|
|
2308
|
+
|
|
2309
|
+
# Filter duplicates (prefer lock files)
|
|
2310
|
+
manifest_paths = ManifestParser.filter_duplicate_dependency_files(manifest_paths)
|
|
2311
|
+
self._debug(f"Found {len(manifest_paths)} manifest file(s) in '{repo_name}'")
|
|
2312
|
+
|
|
2313
|
+
for abs_path in manifest_paths:
|
|
2314
|
+
rel_path = os.path.relpath(abs_path, local_path)
|
|
2315
|
+
try:
|
|
2316
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
2317
|
+
content = fh.read()
|
|
2318
|
+
|
|
2319
|
+
packages = ManifestParser.parse_dependency_file(rel_path, content)
|
|
2320
|
+
registry = self._get_registry_for_manifest(rel_path)
|
|
2321
|
+
|
|
2322
|
+
pkg_dicts = [
|
|
2323
|
+
{"name": name, "version": ver, "latest_version": latest}
|
|
2324
|
+
for name, ver, latest in packages
|
|
2325
|
+
]
|
|
2326
|
+
|
|
2327
|
+
results.append(ManifestResult(
|
|
2328
|
+
manifest_path=rel_path,
|
|
2329
|
+
repo_name=repo_name,
|
|
2330
|
+
registry=registry,
|
|
2331
|
+
packages=pkg_dicts,
|
|
2332
|
+
))
|
|
2333
|
+
self._debug(f" {rel_path}: {len(pkg_dicts)} packages ({registry})")
|
|
2334
|
+
|
|
2335
|
+
except Exception as exc:
|
|
2336
|
+
results.append(ManifestResult(
|
|
2337
|
+
manifest_path=rel_path,
|
|
2338
|
+
repo_name=repo_name,
|
|
2339
|
+
registry="unknown",
|
|
2340
|
+
packages=[],
|
|
2341
|
+
error=str(exc),
|
|
2342
|
+
))
|
|
2343
|
+
|
|
2344
|
+
return results
|
|
2345
|
+
|
|
2346
|
+
# ----- Source-import extraction -----
|
|
2347
|
+
|
|
2348
|
+
def scan_source_imports(
|
|
2349
|
+
self,
|
|
2350
|
+
files: list[dict],
|
|
2351
|
+
scope_dirs: dict[str, str],
|
|
2352
|
+
) -> list[ImportResult]:
|
|
2353
|
+
"""
|
|
2354
|
+
Extract external package names from source file imports.
|
|
2355
|
+
|
|
2356
|
+
Args:
|
|
2357
|
+
files: File dicts from the audit plan (must include
|
|
2358
|
+
``file_path``, ``relative_path``, ``repo_name``).
|
|
2359
|
+
scope_dirs: Mapping of ``repo_name → local_path``.
|
|
2360
|
+
|
|
2361
|
+
Returns:
|
|
2362
|
+
One ``ImportResult`` per file that yielded imports.
|
|
2363
|
+
"""
|
|
2364
|
+
results: list[ImportResult] = []
|
|
2365
|
+
|
|
2366
|
+
for f in files:
|
|
2367
|
+
file_path = f.get("file_path", "")
|
|
2368
|
+
rel_path = f.get("relative_path", "")
|
|
2369
|
+
repo_name = f.get("repo_name", "")
|
|
2370
|
+
local_root = scope_dirs.get(repo_name, "")
|
|
2371
|
+
|
|
2372
|
+
if not local_root:
|
|
2373
|
+
continue
|
|
2374
|
+
|
|
2375
|
+
_, ext = os.path.splitext(rel_path)
|
|
2376
|
+
if not ext:
|
|
2377
|
+
continue
|
|
2378
|
+
|
|
2379
|
+
# Get registry prefix for this extension from server config
|
|
2380
|
+
registry_prefix = self._registry_prefix_map.get(ext.lower(), "")
|
|
2381
|
+
if not registry_prefix:
|
|
2382
|
+
continue
|
|
2383
|
+
|
|
2384
|
+
abs_path = os.path.join(local_root, rel_path)
|
|
2385
|
+
if not os.path.isfile(abs_path):
|
|
2386
|
+
continue
|
|
2387
|
+
|
|
2388
|
+
try:
|
|
2389
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
2390
|
+
content = fh.read()
|
|
2391
|
+
|
|
2392
|
+
packages = SourceImportExtractor.parse_packages(ext, content)
|
|
2393
|
+
if packages:
|
|
2394
|
+
results.append(ImportResult(
|
|
2395
|
+
file_path=file_path,
|
|
2396
|
+
registry_prefix=registry_prefix,
|
|
2397
|
+
packages=packages,
|
|
2398
|
+
))
|
|
2399
|
+
except Exception as exc:
|
|
2400
|
+
results.append(ImportResult(
|
|
2401
|
+
file_path=file_path,
|
|
2402
|
+
registry_prefix=registry_prefix,
|
|
2403
|
+
packages=[],
|
|
2404
|
+
error=str(exc),
|
|
2405
|
+
))
|
|
2406
|
+
|
|
2407
|
+
if results:
|
|
2408
|
+
total_pkgs = sum(len(r.packages) for r in results)
|
|
2409
|
+
self._debug(f"Extracted imports from {len(results)} file(s) ({total_pkgs} packages total)")
|
|
2410
|
+
|
|
2411
|
+
return results
|
|
2412
|
+
|
|
2413
|
+
# ----- Vulnerability scanning -----
|
|
2414
|
+
|
|
2415
|
+
def scan_vulnerabilities(
|
|
2416
|
+
self,
|
|
2417
|
+
manifest_results: list[ManifestResult],
|
|
2418
|
+
import_results: list[ImportResult],
|
|
2419
|
+
) -> dict[str, dict]:
|
|
2420
|
+
"""
|
|
2421
|
+
Query OSV for vulnerabilities of all discovered packages.
|
|
2422
|
+
|
|
2423
|
+
Returns a dict keyed by prefixed package name (e.g.
|
|
2424
|
+
``"$!npm$!_react"``) with vulnerability summary dicts.
|
|
2425
|
+
"""
|
|
2426
|
+
# Collect unique (prefixed_name, version) pairs
|
|
2427
|
+
seen: set[str] = set()
|
|
2428
|
+
queries: list[dict] = []
|
|
2429
|
+
|
|
2430
|
+
for mr in manifest_results:
|
|
2431
|
+
# Map registry to prefix
|
|
2432
|
+
prefix = ""
|
|
2433
|
+
for ext_prefix, reg in (self._config.get("prefix_to_registry") or {}).items():
|
|
2434
|
+
if reg == mr.registry:
|
|
2435
|
+
prefix = ext_prefix
|
|
2436
|
+
break
|
|
2437
|
+
if not prefix:
|
|
2438
|
+
# Construct from registry name
|
|
2439
|
+
prefix = f"$!{mr.registry}$!_"
|
|
2440
|
+
|
|
2441
|
+
for pkg in mr.packages:
|
|
2442
|
+
prefixed = f"{prefix}{pkg['name']}"
|
|
2443
|
+
if prefixed not in seen:
|
|
2444
|
+
seen.add(prefixed)
|
|
2445
|
+
queries.append({
|
|
2446
|
+
"prefixed_name": prefixed,
|
|
2447
|
+
"version": pkg.get("version"),
|
|
2448
|
+
})
|
|
2449
|
+
|
|
2450
|
+
for ir in import_results:
|
|
2451
|
+
for pkg_name in ir.packages:
|
|
2452
|
+
prefixed = f"{ir.registry_prefix}{pkg_name}"
|
|
2453
|
+
if prefixed not in seen:
|
|
2454
|
+
seen.add(prefixed)
|
|
2455
|
+
queries.append({"prefixed_name": prefixed, "version": None})
|
|
2456
|
+
|
|
2457
|
+
self._debug(f"Scanning {len(queries)} unique packages for vulnerabilities")
|
|
2458
|
+
return self._vuln_scanner.scan_batch(queries)
|
|
2459
|
+
|
|
2460
|
+
# ----- Helpers -----
|
|
2461
|
+
|
|
2462
|
+
def _get_registry_for_manifest(self, rel_path: str) -> str:
|
|
2463
|
+
"""Determine the package registry for a manifest file path."""
|
|
2464
|
+
filename = os.path.basename(rel_path)
|
|
2465
|
+
filename_lower = filename.lower()
|
|
2466
|
+
|
|
2467
|
+
# Check server-provided mapping first
|
|
2468
|
+
if filename in self._dep_file_prefixes:
|
|
2469
|
+
return self._dep_file_prefixes[filename].lstrip("!")
|
|
2470
|
+
if filename_lower in self._dep_file_prefixes:
|
|
2471
|
+
return self._dep_file_prefixes[filename_lower].lstrip("!")
|
|
2472
|
+
|
|
2473
|
+
# Fallback: check path patterns
|
|
2474
|
+
for pattern, prefix in self._dep_file_prefixes.items():
|
|
2475
|
+
if rel_path.endswith(pattern):
|
|
2476
|
+
return prefix.lstrip("!")
|
|
2477
|
+
|
|
2478
|
+
return "unknown"
|