narvy-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/sca/web_deps.py
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
"""Dependency CVE scanning for web/backend source trees: parses manifests and
|
|
2
|
+
lockfiles (npm, PyPI, Packagist, Go, RubyGems, crates.io, Maven, NuGet), then
|
|
3
|
+
queries OSV through osv_client."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
from .osv_client import OSVClient, get_default_client
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_MAX_DETAIL_QUERIES = 400
|
|
18
|
+
|
|
19
|
+
SUPPORTED_ECOSYSTEMS_LABEL = "npm/PyPI/Packagist/Go/RubyGems/crates.io/Maven/NuGet"
|
|
20
|
+
|
|
21
|
+
_MANIFEST_WALK_MAX_DEPTH = 6
|
|
22
|
+
_MAX_MANIFEST_FILES = 300
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _WebDep:
|
|
27
|
+
name: str
|
|
28
|
+
version: str
|
|
29
|
+
ecosystem: str
|
|
30
|
+
source: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_NPM_RANGE_PREFIX_RE = re.compile(r'^[\^~><=\s]+')
|
|
34
|
+
_NPM_NONREGISTRY_RE = re.compile(r'^(file:|link:|git\+|git:|github:|workspace:|https?://|npm:)', re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _strip_npm_range(spec: str) -> Optional[str]:
|
|
38
|
+
"""Exact version from a package.json range specifier, or None if not pinnable."""
|
|
39
|
+
spec = (spec or "").strip()
|
|
40
|
+
if not spec or spec in ("*", "latest"):
|
|
41
|
+
return None
|
|
42
|
+
if _NPM_NONREGISTRY_RE.match(spec):
|
|
43
|
+
return None
|
|
44
|
+
if "||" in spec or " - " in spec or " " in spec.strip():
|
|
45
|
+
return None
|
|
46
|
+
cleaned = _NPM_RANGE_PREFIX_RE.sub("", spec).strip()
|
|
47
|
+
if not cleaned or not re.match(r'^\d', cleaned):
|
|
48
|
+
return None
|
|
49
|
+
return cleaned
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_package_lock(path: str) -> List[_WebDep]:
|
|
53
|
+
try:
|
|
54
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
55
|
+
data = json.load(f)
|
|
56
|
+
except (OSError, json.JSONDecodeError):
|
|
57
|
+
return []
|
|
58
|
+
deps: List[_WebDep] = []
|
|
59
|
+
# lockfileVersion 2/3: flat "packages" map keyed by "node_modules/<name>";
|
|
60
|
+
# the root project is keyed "".
|
|
61
|
+
packages = data.get("packages")
|
|
62
|
+
if isinstance(packages, dict):
|
|
63
|
+
for key, meta in packages.items():
|
|
64
|
+
if not key or not isinstance(meta, dict):
|
|
65
|
+
continue
|
|
66
|
+
version = meta.get("version")
|
|
67
|
+
if not version:
|
|
68
|
+
continue
|
|
69
|
+
name = key.split("node_modules/")[-1]
|
|
70
|
+
if not name:
|
|
71
|
+
continue
|
|
72
|
+
deps.append(_WebDep(name, version, "npm", "package-lock.json"))
|
|
73
|
+
if deps:
|
|
74
|
+
return deps
|
|
75
|
+
# lockfileVersion 1: nested "dependencies" map.
|
|
76
|
+
dependencies = data.get("dependencies")
|
|
77
|
+
if isinstance(dependencies, dict):
|
|
78
|
+
for name, meta in dependencies.items():
|
|
79
|
+
if isinstance(meta, dict) and meta.get("version"):
|
|
80
|
+
deps.append(_WebDep(name, meta["version"], "npm", "package-lock.json"))
|
|
81
|
+
return deps
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_package_json(path: str) -> List[_WebDep]:
|
|
85
|
+
try:
|
|
86
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
87
|
+
data = json.load(f)
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
return []
|
|
90
|
+
deps: List[_WebDep] = []
|
|
91
|
+
for key in ("dependencies", "devDependencies"):
|
|
92
|
+
section = data.get(key)
|
|
93
|
+
if not isinstance(section, dict):
|
|
94
|
+
continue
|
|
95
|
+
for name, spec in section.items():
|
|
96
|
+
version = _strip_npm_range(str(spec))
|
|
97
|
+
if version:
|
|
98
|
+
deps.append(_WebDep(name, version, "npm", "package.json"))
|
|
99
|
+
return deps
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# pnpm-lock.yaml `packages:` keys carry name@version; the leading "/" and a
|
|
103
|
+
# "(react@18)" peer suffix drift across lock versions, so match tolerantly.
|
|
104
|
+
_PNPM_PKG_KEY_RE = re.compile(r'^/?((?:@[^/@]+/)?[^/@][^@]*)@(\d[^()]*)')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_pnpm_lock(path: str) -> List[_WebDep]:
|
|
108
|
+
try:
|
|
109
|
+
import yaml
|
|
110
|
+
except ModuleNotFoundError:
|
|
111
|
+
return []
|
|
112
|
+
try:
|
|
113
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
114
|
+
data = yaml.safe_load(f)
|
|
115
|
+
except (OSError, yaml.YAMLError):
|
|
116
|
+
return []
|
|
117
|
+
if not isinstance(data, dict):
|
|
118
|
+
return []
|
|
119
|
+
section = data.get("packages") or data.get("snapshots") or {}
|
|
120
|
+
if not isinstance(section, dict):
|
|
121
|
+
return []
|
|
122
|
+
seen = set()
|
|
123
|
+
deps: List[_WebDep] = []
|
|
124
|
+
for key in section:
|
|
125
|
+
m = _PNPM_PKG_KEY_RE.match(str(key))
|
|
126
|
+
if not m:
|
|
127
|
+
continue
|
|
128
|
+
name, version = m.group(1), m.group(2).strip()
|
|
129
|
+
if (name, version) in seen:
|
|
130
|
+
continue
|
|
131
|
+
seen.add((name, version))
|
|
132
|
+
deps.append(_WebDep(name, version, "npm", "pnpm-lock.yaml"))
|
|
133
|
+
return deps
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _collect_npm_deps(root_path: str) -> List[_WebDep]:
|
|
137
|
+
# pnpm-lock.yaml is authoritative for pnpm workspaces; prefer it.
|
|
138
|
+
pnpm = os.path.join(root_path, "pnpm-lock.yaml")
|
|
139
|
+
if os.path.isfile(pnpm):
|
|
140
|
+
locked = _parse_pnpm_lock(pnpm)
|
|
141
|
+
if locked:
|
|
142
|
+
return locked
|
|
143
|
+
lock = os.path.join(root_path, "package-lock.json")
|
|
144
|
+
if os.path.isfile(lock):
|
|
145
|
+
locked = _parse_package_lock(lock)
|
|
146
|
+
if locked:
|
|
147
|
+
return locked
|
|
148
|
+
manifest = os.path.join(root_path, "package.json")
|
|
149
|
+
if os.path.isfile(manifest):
|
|
150
|
+
return _parse_package_json(manifest)
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_REQ_EXACT_RE = re.compile(r'^([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([0-9][A-Za-z0-9.*+!_-]*)')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_poetry_lock(path: str) -> List[_WebDep]:
|
|
158
|
+
"""Parse poetry.lock (TOML [[package]] tables) for resolved Python deps."""
|
|
159
|
+
try:
|
|
160
|
+
import tomllib as _toml # py3.11+
|
|
161
|
+
except ModuleNotFoundError:
|
|
162
|
+
try:
|
|
163
|
+
import tomli as _toml
|
|
164
|
+
except ModuleNotFoundError:
|
|
165
|
+
return []
|
|
166
|
+
try:
|
|
167
|
+
with open(path, "rb") as f:
|
|
168
|
+
data = _toml.load(f)
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
return []
|
|
171
|
+
deps: List[_WebDep] = []
|
|
172
|
+
for pkg in (data.get("package") or []):
|
|
173
|
+
name = pkg.get("name")
|
|
174
|
+
version = pkg.get("version")
|
|
175
|
+
if name and version and re.match(r'^\d', str(version)):
|
|
176
|
+
deps.append(_WebDep(str(name), str(version), "PyPI", "poetry.lock"))
|
|
177
|
+
return deps
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _collect_pypi_deps(root_path: str) -> List[_WebDep]:
|
|
181
|
+
# poetry.lock is authoritative and carries the full resolved tree; prefer it.
|
|
182
|
+
poetry = os.path.join(root_path, "poetry.lock")
|
|
183
|
+
if os.path.isfile(poetry):
|
|
184
|
+
locked = _parse_poetry_lock(poetry)
|
|
185
|
+
if locked:
|
|
186
|
+
return locked
|
|
187
|
+
path = os.path.join(root_path, "requirements.txt")
|
|
188
|
+
if not os.path.isfile(path):
|
|
189
|
+
return []
|
|
190
|
+
deps: List[_WebDep] = []
|
|
191
|
+
try:
|
|
192
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
193
|
+
lines = f.readlines()
|
|
194
|
+
except OSError:
|
|
195
|
+
return []
|
|
196
|
+
for raw_line in lines:
|
|
197
|
+
line = raw_line.strip()
|
|
198
|
+
if not line or line.startswith(("#", "-e ", "-r ", "--")):
|
|
199
|
+
continue
|
|
200
|
+
line = line.split(";", 1)[0].split("#", 1)[0].strip()
|
|
201
|
+
m = _REQ_EXACT_RE.match(line)
|
|
202
|
+
if not m:
|
|
203
|
+
continue
|
|
204
|
+
name, version = m.group(1), m.group(2)
|
|
205
|
+
deps.append(_WebDep(name, version, "PyPI", "requirements.txt"))
|
|
206
|
+
return deps
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_PHP_RANGE_PREFIX_RE = re.compile(r'^[\^~><=\s]+')
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _strip_php_range(spec: str) -> Optional[str]:
|
|
213
|
+
spec = (spec or "").strip()
|
|
214
|
+
if not spec or spec in ("*", "dev-master", "dev-main"):
|
|
215
|
+
return None
|
|
216
|
+
if spec.startswith(("dev-", "@")):
|
|
217
|
+
return None
|
|
218
|
+
if "||" in spec or " " in spec or "," in spec:
|
|
219
|
+
return None
|
|
220
|
+
cleaned = _PHP_RANGE_PREFIX_RE.sub("", spec).strip()
|
|
221
|
+
if not cleaned or not re.match(r'^\d', cleaned):
|
|
222
|
+
return None
|
|
223
|
+
return cleaned
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_composer_lock(path: str) -> List[_WebDep]:
|
|
227
|
+
try:
|
|
228
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
229
|
+
data = json.load(f)
|
|
230
|
+
except (OSError, json.JSONDecodeError):
|
|
231
|
+
return []
|
|
232
|
+
deps: List[_WebDep] = []
|
|
233
|
+
for key in ("packages", "packages-dev"):
|
|
234
|
+
for entry in data.get(key, []) or []:
|
|
235
|
+
if not isinstance(entry, dict):
|
|
236
|
+
continue
|
|
237
|
+
name = entry.get("name")
|
|
238
|
+
version = entry.get("version")
|
|
239
|
+
if name and version:
|
|
240
|
+
# composer.lock versions are sometimes prefixed "v".
|
|
241
|
+
deps.append(_WebDep(name, str(version).lstrip("vV"), "Packagist", "composer.lock"))
|
|
242
|
+
return deps
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_composer_json(path: str) -> List[_WebDep]:
|
|
246
|
+
try:
|
|
247
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
248
|
+
data = json.load(f)
|
|
249
|
+
except (OSError, json.JSONDecodeError):
|
|
250
|
+
return []
|
|
251
|
+
deps: List[_WebDep] = []
|
|
252
|
+
for key in ("require", "require-dev"):
|
|
253
|
+
section = data.get(key)
|
|
254
|
+
if not isinstance(section, dict):
|
|
255
|
+
continue
|
|
256
|
+
for name, spec in section.items():
|
|
257
|
+
if name == "php" or name.startswith("ext-"):
|
|
258
|
+
continue
|
|
259
|
+
version = _strip_php_range(str(spec))
|
|
260
|
+
if version:
|
|
261
|
+
deps.append(_WebDep(name, version, "Packagist", "composer.json"))
|
|
262
|
+
return deps
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _collect_packagist_deps(root_path: str) -> List[_WebDep]:
|
|
266
|
+
lock = os.path.join(root_path, "composer.lock")
|
|
267
|
+
if os.path.isfile(lock):
|
|
268
|
+
locked = _parse_composer_lock(lock)
|
|
269
|
+
if locked:
|
|
270
|
+
return locked
|
|
271
|
+
manifest = os.path.join(root_path, "composer.json")
|
|
272
|
+
if os.path.isfile(manifest):
|
|
273
|
+
return _parse_composer_json(manifest)
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# go.mod versions are always exact ("v" + semver), so no pinnability filtering.
|
|
278
|
+
_GO_REQUIRE_LINE_RE = re.compile(
|
|
279
|
+
r'^\s*([A-Za-z0-9][\w.\-/]*)\s+(v[0-9][\w.+-]*)(?:\s*//.*)?$'
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _collect_go_deps(root_path: str) -> List[_WebDep]:
|
|
284
|
+
path = os.path.join(root_path, "go.mod")
|
|
285
|
+
if not os.path.isfile(path):
|
|
286
|
+
return []
|
|
287
|
+
deps: List[_WebDep] = []
|
|
288
|
+
try:
|
|
289
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
290
|
+
content = f.read()
|
|
291
|
+
except OSError:
|
|
292
|
+
return []
|
|
293
|
+
in_block = False
|
|
294
|
+
for raw_line in content.splitlines():
|
|
295
|
+
line = raw_line.strip()
|
|
296
|
+
if line.startswith("require ("):
|
|
297
|
+
in_block = True
|
|
298
|
+
continue
|
|
299
|
+
if in_block and line == ")":
|
|
300
|
+
in_block = False
|
|
301
|
+
continue
|
|
302
|
+
if line.startswith("require "):
|
|
303
|
+
line = line[len("require "):].strip()
|
|
304
|
+
elif not in_block:
|
|
305
|
+
continue
|
|
306
|
+
if line.startswith("//") or not line:
|
|
307
|
+
continue
|
|
308
|
+
m = _GO_REQUIRE_LINE_RE.match(line)
|
|
309
|
+
if not m:
|
|
310
|
+
continue
|
|
311
|
+
module_path, version = m.group(1), m.group(2)
|
|
312
|
+
deps.append(_WebDep(module_path, version, "Go", "go.mod"))
|
|
313
|
+
return deps
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Gemfile.lock spec lines are 4-space-indented; 6-space are transitive ranges.
|
|
317
|
+
_GEMFILE_LOCK_SPEC_RE = re.compile(r'^ {4}([A-Za-z0-9][\w.-]*) \((\d[^)]*)\)\s*$')
|
|
318
|
+
|
|
319
|
+
# Strip Bundler's platform suffix ("1.16.0-x86_64-linux"); anchored on the CPU/OS
|
|
320
|
+
# token so a prerelease hyphen ("1.0.0-beta1") survives.
|
|
321
|
+
_GEM_PLATFORM_SUFFIX_RE = re.compile(
|
|
322
|
+
r'-(?:x86_64|x86|x64|arm64|aarch64|arm|universal|riscv64|ppc64le|ppc64|s390x'
|
|
323
|
+
r'|java|dalvik|mswin\d*|mingw\d*)'
|
|
324
|
+
r'(?:[-_][\w.]+)*$'
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _collect_rubygems_deps(root_path: str) -> List[_WebDep]:
|
|
329
|
+
"""Parse only Gemfile.lock's `GEM ... specs:` section (not GIT/PATH/PLUGIN sources)."""
|
|
330
|
+
path = os.path.join(root_path, "Gemfile.lock")
|
|
331
|
+
if not os.path.isfile(path):
|
|
332
|
+
return []
|
|
333
|
+
try:
|
|
334
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
335
|
+
lines = f.readlines()
|
|
336
|
+
except OSError:
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
deps: List[_WebDep] = []
|
|
340
|
+
in_gem_section = False
|
|
341
|
+
in_specs = False
|
|
342
|
+
for raw_line in lines:
|
|
343
|
+
line = raw_line.rstrip("\n")
|
|
344
|
+
if line and not line.startswith(" "):
|
|
345
|
+
in_gem_section = line.strip() == "GEM"
|
|
346
|
+
in_specs = False
|
|
347
|
+
continue
|
|
348
|
+
if not in_gem_section:
|
|
349
|
+
continue
|
|
350
|
+
if line.strip() == "specs:":
|
|
351
|
+
in_specs = True
|
|
352
|
+
continue
|
|
353
|
+
if not in_specs:
|
|
354
|
+
continue
|
|
355
|
+
m = _GEMFILE_LOCK_SPEC_RE.match(line)
|
|
356
|
+
if not m:
|
|
357
|
+
continue
|
|
358
|
+
name, version = m.group(1), m.group(2)
|
|
359
|
+
version = _GEM_PLATFORM_SUFFIX_RE.sub("", version)
|
|
360
|
+
deps.append(_WebDep(name, version, "RubyGems", "Gemfile.lock"))
|
|
361
|
+
return deps
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Cargo.lock is TOML but parsed line-wise (tomllib is 3.11+; this CLI supports 3.10+).
|
|
365
|
+
_CARGO_LOCK_KV_RE = re.compile(r'^(name|version|source)\s*=\s*"([^"]*)"\s*$')
|
|
366
|
+
_CRATES_IO_REGISTRY_PREFIX = "registry+https://github.com/rust-lang/crates.io-index"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_cargo_lock(path: str) -> List[_WebDep]:
|
|
370
|
+
try:
|
|
371
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
372
|
+
lines = f.readlines()
|
|
373
|
+
except OSError:
|
|
374
|
+
return []
|
|
375
|
+
|
|
376
|
+
deps: List[_WebDep] = []
|
|
377
|
+
cur: Dict[str, str] = {}
|
|
378
|
+
|
|
379
|
+
def _flush() -> None:
|
|
380
|
+
name, version, src = cur.get("name"), cur.get("version"), cur.get("source", "")
|
|
381
|
+
if name and version and src.startswith(_CRATES_IO_REGISTRY_PREFIX):
|
|
382
|
+
deps.append(_WebDep(name, version, "crates.io", "Cargo.lock"))
|
|
383
|
+
|
|
384
|
+
for raw_line in lines:
|
|
385
|
+
line = raw_line.strip()
|
|
386
|
+
if line == "[[package]]":
|
|
387
|
+
_flush()
|
|
388
|
+
cur = {}
|
|
389
|
+
continue
|
|
390
|
+
if line.startswith("["):
|
|
391
|
+
_flush()
|
|
392
|
+
cur = {}
|
|
393
|
+
continue
|
|
394
|
+
m = _CARGO_LOCK_KV_RE.match(line)
|
|
395
|
+
if m:
|
|
396
|
+
cur[m.group(1)] = m.group(2)
|
|
397
|
+
_flush()
|
|
398
|
+
return deps
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
_CARGO_TOML_SIMPLE_DEP_RE = re.compile(r'^([A-Za-z0-9][\w.-]*)\s*=\s*"([^"]*)"\s*$')
|
|
402
|
+
_CARGO_TOML_TABLE_DEP_RE = re.compile(r'^([A-Za-z0-9][\w.-]*)\s*=\s*\{(.*)\}\s*$')
|
|
403
|
+
_CARGO_TOML_TABLE_VERSION_RE = re.compile(r'\bversion\s*=\s*"([^"]*)"')
|
|
404
|
+
_CARGO_TOML_DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
|
|
405
|
+
_CARGO_RANGE_PREFIX_RE = re.compile(r'^[\^~><=\s]+')
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _strip_cargo_range(spec: str) -> Optional[str]:
|
|
409
|
+
spec = (spec or "").strip()
|
|
410
|
+
if not spec or spec == "*":
|
|
411
|
+
return None
|
|
412
|
+
if "," in spec or "||" in spec:
|
|
413
|
+
return None
|
|
414
|
+
cleaned = _CARGO_RANGE_PREFIX_RE.sub("", spec).strip()
|
|
415
|
+
if not cleaned or not re.match(r'^\d', cleaned):
|
|
416
|
+
return None
|
|
417
|
+
return cleaned
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _parse_cargo_toml(path: str) -> List[_WebDep]:
|
|
421
|
+
try:
|
|
422
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
423
|
+
lines = f.readlines()
|
|
424
|
+
except OSError:
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
deps: List[_WebDep] = []
|
|
428
|
+
in_dep_section = False
|
|
429
|
+
for raw_line in lines:
|
|
430
|
+
line = raw_line.split("#", 1)[0].strip()
|
|
431
|
+
if line.startswith("["):
|
|
432
|
+
section = line.strip("[]").strip()
|
|
433
|
+
in_dep_section = section.rsplit(".", 1)[-1] in _CARGO_TOML_DEP_SECTIONS
|
|
434
|
+
continue
|
|
435
|
+
if not in_dep_section or not line:
|
|
436
|
+
continue
|
|
437
|
+
m = _CARGO_TOML_SIMPLE_DEP_RE.match(line)
|
|
438
|
+
if m:
|
|
439
|
+
version = _strip_cargo_range(m.group(2))
|
|
440
|
+
if version:
|
|
441
|
+
deps.append(_WebDep(m.group(1), version, "crates.io", "Cargo.toml"))
|
|
442
|
+
continue
|
|
443
|
+
m = _CARGO_TOML_TABLE_DEP_RE.match(line)
|
|
444
|
+
if m:
|
|
445
|
+
body = m.group(2)
|
|
446
|
+
if "path" in body or "git" in body:
|
|
447
|
+
continue
|
|
448
|
+
vm = _CARGO_TOML_TABLE_VERSION_RE.search(body)
|
|
449
|
+
if vm:
|
|
450
|
+
version = _strip_cargo_range(vm.group(1))
|
|
451
|
+
if version:
|
|
452
|
+
deps.append(_WebDep(m.group(1), version, "crates.io", "Cargo.toml"))
|
|
453
|
+
return deps
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _collect_crates_deps(root_path: str) -> List[_WebDep]:
|
|
457
|
+
lock = os.path.join(root_path, "Cargo.lock")
|
|
458
|
+
if os.path.isfile(lock):
|
|
459
|
+
locked = _parse_cargo_lock(lock)
|
|
460
|
+
if locked:
|
|
461
|
+
return locked
|
|
462
|
+
manifest = os.path.join(root_path, "Cargo.toml")
|
|
463
|
+
if os.path.isfile(manifest):
|
|
464
|
+
return _parse_cargo_toml(manifest)
|
|
465
|
+
return []
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
_MANIFEST_SKIP_DIR_NAMES = frozenset({
|
|
469
|
+
"node_modules", "vendor", "bower_components",
|
|
470
|
+
".venv", "venv", ".tox", "__pycache__",
|
|
471
|
+
"Pods", "Carthage", "DerivedData",
|
|
472
|
+
".git", ".gradle", ".idea", ".vscode", ".vs",
|
|
473
|
+
"build", "dist", "target", "out", "bin", "obj",
|
|
474
|
+
".next", ".nuxt", ".cache", "coverage", ".pytest_cache",
|
|
475
|
+
"packages",
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _walk_manifests(root_path: str, matcher) -> List[str]:
|
|
480
|
+
"""Bounded, vendor-pruned walk for matching paths, sorted for a deterministic cap."""
|
|
481
|
+
found: List[str] = []
|
|
482
|
+
base_depth = root_path.rstrip(os.sep).count(os.sep)
|
|
483
|
+
for cur_root, dirs, files in os.walk(root_path):
|
|
484
|
+
depth = cur_root.rstrip(os.sep).count(os.sep) - base_depth
|
|
485
|
+
if depth >= _MANIFEST_WALK_MAX_DEPTH:
|
|
486
|
+
dirs[:] = []
|
|
487
|
+
continue
|
|
488
|
+
dirs[:] = [d for d in dirs
|
|
489
|
+
if d not in _MANIFEST_SKIP_DIR_NAMES and not d.startswith(".")]
|
|
490
|
+
for f in files:
|
|
491
|
+
if matcher(f):
|
|
492
|
+
found.append(os.path.join(cur_root, f))
|
|
493
|
+
if len(found) >= _MAX_MANIFEST_FILES:
|
|
494
|
+
break
|
|
495
|
+
return sorted(found)[:_MAX_MANIFEST_FILES]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ElementTree has no namespace-agnostic search, so lookups go through _xml_local_name.
|
|
499
|
+
_MAVEN_PROPERTY_RE = re.compile(r'\$\{([^}]+)\}')
|
|
500
|
+
|
|
501
|
+
_MAVEN_CONCRETE_VERSION_RE = re.compile(r'^\d[\w.\-+]*$')
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _xml_local_name(tag: str) -> str:
|
|
505
|
+
return tag.rsplit("}", 1)[-1]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _xml_children(element, name: str):
|
|
509
|
+
for child in element:
|
|
510
|
+
if _xml_local_name(child.tag) == name:
|
|
511
|
+
yield child
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _xml_text(element, name: str) -> Optional[str]:
|
|
515
|
+
for child in _xml_children(element, name):
|
|
516
|
+
return (child.text or "").strip()
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _parse_pom(path: str):
|
|
521
|
+
"""(properties, managed_versions, declared_dependencies) for one pom, versions raw."""
|
|
522
|
+
import xml.etree.ElementTree as ET
|
|
523
|
+
|
|
524
|
+
properties: Dict[str, str] = {}
|
|
525
|
+
managed: Dict[Tuple[str, str], str] = {}
|
|
526
|
+
declared: List[Tuple[str, str, Optional[str]]] = []
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
root = ET.parse(path).getroot()
|
|
530
|
+
except (OSError, ET.ParseError):
|
|
531
|
+
return properties, managed, declared
|
|
532
|
+
|
|
533
|
+
for props in _xml_children(root, "properties"):
|
|
534
|
+
for prop in props:
|
|
535
|
+
properties[_xml_local_name(prop.tag)] = (prop.text or "").strip()
|
|
536
|
+
|
|
537
|
+
def _read_dependency_list(container, into_managed: bool) -> None:
|
|
538
|
+
for dep in _xml_children(container, "dependency"):
|
|
539
|
+
group = _xml_text(dep, "groupId")
|
|
540
|
+
artifact = _xml_text(dep, "artifactId")
|
|
541
|
+
version = _xml_text(dep, "version")
|
|
542
|
+
if not group or not artifact:
|
|
543
|
+
continue
|
|
544
|
+
if into_managed:
|
|
545
|
+
if version:
|
|
546
|
+
managed[(group, artifact)] = version
|
|
547
|
+
# A `<scope>import</scope>` entry is a BOM, not a shipped dependency.
|
|
548
|
+
if _xml_text(dep, "scope") == "import":
|
|
549
|
+
continue
|
|
550
|
+
declared.append((group, artifact, version))
|
|
551
|
+
|
|
552
|
+
for dep_mgmt in _xml_children(root, "dependencyManagement"):
|
|
553
|
+
for deps in _xml_children(dep_mgmt, "dependencies"):
|
|
554
|
+
_read_dependency_list(deps, into_managed=True)
|
|
555
|
+
|
|
556
|
+
for deps in _xml_children(root, "dependencies"):
|
|
557
|
+
_read_dependency_list(deps, into_managed=False)
|
|
558
|
+
|
|
559
|
+
return properties, managed, declared
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _resolve_maven_version(raw: Optional[str], properties: Dict[str, str],
|
|
563
|
+
depth: int = 0) -> Optional[str]:
|
|
564
|
+
"""Concrete version for a raw `<version>`; ranges/SNAPSHOTs/unresolved `${...}` return None."""
|
|
565
|
+
if not raw or depth > 5:
|
|
566
|
+
return None
|
|
567
|
+
raw = raw.strip()
|
|
568
|
+
if not raw:
|
|
569
|
+
return None
|
|
570
|
+
if _MAVEN_PROPERTY_RE.search(raw):
|
|
571
|
+
# `${project.*}` / `${pom.*}` refer to the module being built, not an artifact.
|
|
572
|
+
def _expand(match):
|
|
573
|
+
key = match.group(1)
|
|
574
|
+
if key.startswith(("project.", "pom.")):
|
|
575
|
+
return ""
|
|
576
|
+
return properties.get(key, "")
|
|
577
|
+
expanded = _MAVEN_PROPERTY_RE.sub(_expand, raw)
|
|
578
|
+
if not expanded or _MAVEN_PROPERTY_RE.search(expanded):
|
|
579
|
+
return None
|
|
580
|
+
return _resolve_maven_version(expanded, properties, depth + 1)
|
|
581
|
+
if raw.upper() in ("LATEST", "RELEASE"):
|
|
582
|
+
return None
|
|
583
|
+
if raw.upper().endswith("-SNAPSHOT"):
|
|
584
|
+
return None
|
|
585
|
+
if not _MAVEN_CONCRETE_VERSION_RE.match(raw):
|
|
586
|
+
return None
|
|
587
|
+
return raw
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# Only the literal `group:artifact:version` form; catalogs/interpolation/BOMs are missed.
|
|
591
|
+
_GRADLE_DEP_RE = re.compile(
|
|
592
|
+
r'''(?:^|\s)(?:api|implementation|compile|compileOnly|runtimeOnly|annotationProcessor'''
|
|
593
|
+
r'''|kapt|ksp|testImplementation|testCompile|androidTestImplementation|classpath)'''
|
|
594
|
+
r'''\s*[(\s]\s*["']([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+):([^"'$]+)["']'''
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _parse_gradle(path: str) -> List[Tuple[str, str, str]]:
|
|
599
|
+
try:
|
|
600
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
601
|
+
content = f.read()
|
|
602
|
+
except OSError:
|
|
603
|
+
return []
|
|
604
|
+
out: List[Tuple[str, str, str]] = []
|
|
605
|
+
for line in content.splitlines():
|
|
606
|
+
stripped = line.strip()
|
|
607
|
+
if stripped.startswith(("//", "*", "/*")):
|
|
608
|
+
continue
|
|
609
|
+
m = _GRADLE_DEP_RE.search(line)
|
|
610
|
+
if m:
|
|
611
|
+
out.append((m.group(1), m.group(2), m.group(3).strip()))
|
|
612
|
+
return out
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _collect_maven_deps(root_path: str) -> List[_WebDep]:
|
|
616
|
+
"""Collect Maven/Gradle deps: merge poms' properties repo-wide, then resolve each."""
|
|
617
|
+
pom_paths = _walk_manifests(root_path, lambda f: f == "pom.xml")
|
|
618
|
+
gradle_paths = _walk_manifests(
|
|
619
|
+
root_path, lambda f: f in ("build.gradle", "build.gradle.kts")
|
|
620
|
+
)
|
|
621
|
+
if not pom_paths and not gradle_paths:
|
|
622
|
+
return []
|
|
623
|
+
|
|
624
|
+
all_properties: Dict[str, str] = {}
|
|
625
|
+
all_managed: Dict[Tuple[str, str], str] = {}
|
|
626
|
+
all_declared: List[Tuple[str, str, Optional[str], str]] = []
|
|
627
|
+
|
|
628
|
+
parsed = []
|
|
629
|
+
for pom_path in pom_paths:
|
|
630
|
+
properties, managed, declared = _parse_pom(pom_path)
|
|
631
|
+
parsed.append((pom_path, declared))
|
|
632
|
+
all_properties.update(properties)
|
|
633
|
+
all_managed.update(managed)
|
|
634
|
+
|
|
635
|
+
for pom_path, declared in parsed:
|
|
636
|
+
source = os.path.relpath(pom_path, root_path)
|
|
637
|
+
for group, artifact, raw_version in declared:
|
|
638
|
+
all_declared.append((group, artifact, raw_version, source))
|
|
639
|
+
|
|
640
|
+
deps: List[_WebDep] = []
|
|
641
|
+
for group, artifact, raw_version, source in all_declared:
|
|
642
|
+
version = _resolve_maven_version(raw_version, all_properties)
|
|
643
|
+
if version is None:
|
|
644
|
+
managed_raw = all_managed.get((group, artifact))
|
|
645
|
+
version = _resolve_maven_version(managed_raw, all_properties)
|
|
646
|
+
if version is None:
|
|
647
|
+
continue
|
|
648
|
+
deps.append(_WebDep(f"{group}:{artifact}", version, "Maven", source))
|
|
649
|
+
|
|
650
|
+
for gradle_path in gradle_paths:
|
|
651
|
+
source = os.path.relpath(gradle_path, root_path)
|
|
652
|
+
for group, artifact, version in _parse_gradle(gradle_path):
|
|
653
|
+
if _MAVEN_CONCRETE_VERSION_RE.match(version) and not version.upper().endswith("-SNAPSHOT"):
|
|
654
|
+
deps.append(_WebDep(f"{group}:{artifact}", version, "Maven", source))
|
|
655
|
+
|
|
656
|
+
return deps
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
_NUGET_PROJECT_SUFFIXES = (".csproj", ".fsproj", ".vbproj", ".props", ".targets")
|
|
660
|
+
_MSBUILD_PROPERTY_RE = re.compile(r'\$\(([^)]+)\)')
|
|
661
|
+
# `[8.0.2]` pins one version; other bracket forms are intervals resolved at restore.
|
|
662
|
+
_NUGET_EXACT_PIN_RE = re.compile(r'^\[\s*([^,\[\]()]+?)\s*\]$')
|
|
663
|
+
_NUGET_CONCRETE_VERSION_RE = re.compile(r'^\d[\w.\-+]*$')
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _normalize_nuget_version(raw: Optional[str], properties: Dict[str, str],
|
|
667
|
+
depth: int = 0) -> Optional[str]:
|
|
668
|
+
"""Concrete version for a NuGet version string, or None for a range."""
|
|
669
|
+
if not raw or depth > 5:
|
|
670
|
+
return None
|
|
671
|
+
raw = raw.strip()
|
|
672
|
+
if not raw:
|
|
673
|
+
return None
|
|
674
|
+
if _MSBUILD_PROPERTY_RE.search(raw):
|
|
675
|
+
def _expand(match):
|
|
676
|
+
return properties.get(match.group(1), "")
|
|
677
|
+
expanded = _MSBUILD_PROPERTY_RE.sub(_expand, raw)
|
|
678
|
+
if not expanded or _MSBUILD_PROPERTY_RE.search(expanded):
|
|
679
|
+
return None
|
|
680
|
+
return _normalize_nuget_version(expanded, properties, depth + 1)
|
|
681
|
+
pinned = _NUGET_EXACT_PIN_RE.match(raw)
|
|
682
|
+
if pinned:
|
|
683
|
+
raw = pinned.group(1).strip()
|
|
684
|
+
if "*" in raw:
|
|
685
|
+
return None
|
|
686
|
+
if not _NUGET_CONCRETE_VERSION_RE.match(raw):
|
|
687
|
+
return None
|
|
688
|
+
return raw
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _parse_nuget_project(path: str) -> Tuple[Dict[str, str], List[Tuple[str, Optional[str]]]]:
|
|
692
|
+
"""(msbuild_properties, [(package_id, raw_version), ...]) for one project file."""
|
|
693
|
+
import xml.etree.ElementTree as ET
|
|
694
|
+
|
|
695
|
+
properties: Dict[str, str] = {}
|
|
696
|
+
packages: List[Tuple[str, Optional[str]]] = []
|
|
697
|
+
try:
|
|
698
|
+
root = ET.parse(path).getroot()
|
|
699
|
+
except (OSError, ET.ParseError):
|
|
700
|
+
return properties, packages
|
|
701
|
+
|
|
702
|
+
for element in root.iter():
|
|
703
|
+
name = _xml_local_name(element.tag)
|
|
704
|
+
if name == "PropertyGroup":
|
|
705
|
+
for prop in element:
|
|
706
|
+
properties[_xml_local_name(prop.tag)] = (prop.text or "").strip()
|
|
707
|
+
elif name in ("PackageReference", "PackageVersion"):
|
|
708
|
+
package_id = element.get("Include") or element.get("Update")
|
|
709
|
+
if not package_id:
|
|
710
|
+
continue
|
|
711
|
+
version = element.get("Version") or element.get("VersionOverride")
|
|
712
|
+
if version is None:
|
|
713
|
+
version = _xml_text(element, "Version")
|
|
714
|
+
packages.append((package_id.strip(), version))
|
|
715
|
+
return properties, packages
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _parse_packages_config(path: str) -> List[Tuple[str, Optional[str]]]:
|
|
719
|
+
import xml.etree.ElementTree as ET
|
|
720
|
+
|
|
721
|
+
try:
|
|
722
|
+
root = ET.parse(path).getroot()
|
|
723
|
+
except (OSError, ET.ParseError):
|
|
724
|
+
return []
|
|
725
|
+
out: List[Tuple[str, Optional[str]]] = []
|
|
726
|
+
for element in root.iter():
|
|
727
|
+
if _xml_local_name(element.tag) != "package":
|
|
728
|
+
continue
|
|
729
|
+
package_id = element.get("id")
|
|
730
|
+
if package_id:
|
|
731
|
+
out.append((package_id.strip(), element.get("version")))
|
|
732
|
+
return out
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _parse_packages_lock(path: str) -> List[Tuple[str, Optional[str]]]:
|
|
736
|
+
"""`packages.lock.json` entries, keyed {"dependencies": {tfm: {pkg: {resolved}}}}."""
|
|
737
|
+
try:
|
|
738
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
739
|
+
data = json.load(f)
|
|
740
|
+
except (OSError, json.JSONDecodeError):
|
|
741
|
+
return []
|
|
742
|
+
out: List[Tuple[str, Optional[str]]] = []
|
|
743
|
+
frameworks = data.get("dependencies")
|
|
744
|
+
if not isinstance(frameworks, dict):
|
|
745
|
+
return []
|
|
746
|
+
for packages in frameworks.values():
|
|
747
|
+
if not isinstance(packages, dict):
|
|
748
|
+
continue
|
|
749
|
+
for package_id, meta in packages.items():
|
|
750
|
+
if isinstance(meta, dict) and meta.get("resolved"):
|
|
751
|
+
out.append((package_id, str(meta["resolved"])))
|
|
752
|
+
return out
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _collect_nuget_deps(root_path: str) -> List[_WebDep]:
|
|
756
|
+
"""Collect NuGet deps: lockfile-first per project, MSBuild properties merged first."""
|
|
757
|
+
lock_paths = _walk_manifests(root_path, lambda f: f == "packages.lock.json")
|
|
758
|
+
project_paths = _walk_manifests(
|
|
759
|
+
root_path, lambda f: f.endswith(_NUGET_PROJECT_SUFFIXES)
|
|
760
|
+
)
|
|
761
|
+
config_paths = _walk_manifests(root_path, lambda f: f == "packages.config")
|
|
762
|
+
if not lock_paths and not project_paths and not config_paths:
|
|
763
|
+
return []
|
|
764
|
+
|
|
765
|
+
locked_dirs = {os.path.dirname(p) for p in lock_paths}
|
|
766
|
+
|
|
767
|
+
all_properties: Dict[str, str] = {}
|
|
768
|
+
project_packages: List[Tuple[str, List[Tuple[str, Optional[str]]]]] = []
|
|
769
|
+
for project_path in project_paths:
|
|
770
|
+
properties, packages = _parse_nuget_project(project_path)
|
|
771
|
+
all_properties.update(properties)
|
|
772
|
+
project_packages.append((project_path, packages))
|
|
773
|
+
|
|
774
|
+
deps: List[_WebDep] = []
|
|
775
|
+
|
|
776
|
+
for lock_path in lock_paths:
|
|
777
|
+
source = os.path.relpath(lock_path, root_path)
|
|
778
|
+
for package_id, version in _parse_packages_lock(lock_path):
|
|
779
|
+
if version:
|
|
780
|
+
deps.append(_WebDep(package_id, version, "NuGet", source))
|
|
781
|
+
|
|
782
|
+
for project_path, packages in project_packages:
|
|
783
|
+
if os.path.dirname(project_path) in locked_dirs:
|
|
784
|
+
continue
|
|
785
|
+
source = os.path.relpath(project_path, root_path)
|
|
786
|
+
for package_id, raw_version in packages:
|
|
787
|
+
version = _normalize_nuget_version(raw_version, all_properties)
|
|
788
|
+
if version:
|
|
789
|
+
deps.append(_WebDep(package_id, version, "NuGet", source))
|
|
790
|
+
|
|
791
|
+
for config_path in config_paths:
|
|
792
|
+
if os.path.dirname(config_path) in locked_dirs:
|
|
793
|
+
continue
|
|
794
|
+
source = os.path.relpath(config_path, root_path)
|
|
795
|
+
for package_id, raw_version in _parse_packages_config(config_path):
|
|
796
|
+
version = _normalize_nuget_version(raw_version, all_properties)
|
|
797
|
+
if version:
|
|
798
|
+
deps.append(_WebDep(package_id, version, "NuGet", source))
|
|
799
|
+
|
|
800
|
+
return deps
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _dedupe(deps: List[_WebDep]) -> List[_WebDep]:
|
|
804
|
+
best: Dict[Tuple[str, str], _WebDep] = {}
|
|
805
|
+
for dep in deps:
|
|
806
|
+
key = (dep.ecosystem, dep.name)
|
|
807
|
+
if key not in best:
|
|
808
|
+
best[key] = dep
|
|
809
|
+
return list(best.values())
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _finding_from_osv(dep: _WebDep, osv_finding_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
813
|
+
cve_id = osv_finding_dict.get("cve") or osv_finding_dict.get("osv_id") or "UNKNOWN"
|
|
814
|
+
severity = osv_finding_dict.get("severity", "MEDIUM")
|
|
815
|
+
if severity not in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
816
|
+
severity = "MEDIUM"
|
|
817
|
+
fixed_version = osv_finding_dict.get("fixed_version")
|
|
818
|
+
summary = osv_finding_dict.get("summary") or (osv_finding_dict.get("details") or "")[:400] or "No summary available."
|
|
819
|
+
|
|
820
|
+
description = (
|
|
821
|
+
f"{dep.name}@{dep.version} ({dep.ecosystem}) is affected by {cve_id} "
|
|
822
|
+
f"(detected via {dep.source}). {summary}"
|
|
823
|
+
)
|
|
824
|
+
recommendation = (
|
|
825
|
+
f"Update {dep.name} to version {fixed_version} or later."
|
|
826
|
+
if fixed_version
|
|
827
|
+
else f"No fixed version is listed by OSV yet for {dep.name}. "
|
|
828
|
+
f"Check its release notes / consider an alternative package."
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
rule_id = f"SCA-{cve_id}"
|
|
832
|
+
return {
|
|
833
|
+
"rule_id": rule_id,
|
|
834
|
+
"file_path": dep.source,
|
|
835
|
+
"name": f"{cve_id}: {dep.name}@{dep.version} (vulnerable dependency)",
|
|
836
|
+
"severity": severity,
|
|
837
|
+
"details": {
|
|
838
|
+
"description": description,
|
|
839
|
+
"recommendation": recommendation,
|
|
840
|
+
"cwe": osv_finding_dict.get("cwe", "CWE-1104"),
|
|
841
|
+
"masvs": "MSTG-CODE-5",
|
|
842
|
+
},
|
|
843
|
+
"line": 1,
|
|
844
|
+
"engine": "sca",
|
|
845
|
+
"sca": {
|
|
846
|
+
"package": dep.name,
|
|
847
|
+
"version": dep.version,
|
|
848
|
+
"detected_via": dep.source,
|
|
849
|
+
"fixed_version": fixed_version,
|
|
850
|
+
"references": osv_finding_dict.get("references", []),
|
|
851
|
+
"ecosystem": dep.ecosystem,
|
|
852
|
+
},
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _rule_def_from_finding(finding: Dict[str, Any]) -> Dict[str, Any]:
|
|
857
|
+
return {
|
|
858
|
+
"id": finding["rule_id"],
|
|
859
|
+
"name": finding["name"],
|
|
860
|
+
"severity": finding["severity"],
|
|
861
|
+
"masvs": finding["details"]["masvs"],
|
|
862
|
+
"details": dict(finding["details"]),
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
# Run a collector once per manifest-bearing directory, so monorepos and subdir
|
|
867
|
+
# backends are covered (scan()'s _dedupe collapses overlap).
|
|
868
|
+
def _collect_recursive(root_path: str, filenames, collector) -> List[_WebDep]:
|
|
869
|
+
dirs = set()
|
|
870
|
+
for fn in filenames:
|
|
871
|
+
for p in _walk_manifests(root_path, lambda f, _fn=fn: f == _fn):
|
|
872
|
+
dirs.add(os.path.dirname(p))
|
|
873
|
+
if root_path not in dirs and any(
|
|
874
|
+
os.path.isfile(os.path.join(root_path, fn)) for fn in filenames
|
|
875
|
+
):
|
|
876
|
+
dirs.add(root_path)
|
|
877
|
+
out: List[_WebDep] = []
|
|
878
|
+
for d in sorted(dirs):
|
|
879
|
+
out.extend(collector(d))
|
|
880
|
+
return out
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def collect_dependencies(source_dir: str) -> List[_WebDep]:
|
|
884
|
+
deps: List[_WebDep] = []
|
|
885
|
+
deps.extend(_collect_recursive(source_dir, ("pnpm-lock.yaml", "package-lock.json", "package.json"), _collect_npm_deps))
|
|
886
|
+
deps.extend(_collect_recursive(source_dir, ("poetry.lock", "requirements.txt"), _collect_pypi_deps))
|
|
887
|
+
deps.extend(_collect_recursive(source_dir, ("composer.lock", "composer.json"), _collect_packagist_deps))
|
|
888
|
+
deps.extend(_collect_recursive(source_dir, ("go.mod",), _collect_go_deps))
|
|
889
|
+
deps.extend(_collect_recursive(source_dir, ("Gemfile.lock",), _collect_rubygems_deps))
|
|
890
|
+
deps.extend(_collect_recursive(source_dir, ("Cargo.lock", "Cargo.toml"), _collect_crates_deps))
|
|
891
|
+
deps.extend(_collect_maven_deps(source_dir))
|
|
892
|
+
deps.extend(_collect_nuget_deps(source_dir))
|
|
893
|
+
return deps
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def scan(source_dir: str, osv_client: OSVClient = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
|
|
897
|
+
"""Scan a source checkout's manifests: (findings, rule_defs, stats)."""
|
|
898
|
+
client = osv_client or get_default_client()
|
|
899
|
+
|
|
900
|
+
all_deps = collect_dependencies(source_dir)
|
|
901
|
+
deduped = _dedupe(all_deps)
|
|
902
|
+
|
|
903
|
+
# "_detected" is the pre-cap count; "_checked" is what was actually queried.
|
|
904
|
+
stats: Dict[str, Any] = {
|
|
905
|
+
"total_dependencies_detected": len(all_deps),
|
|
906
|
+
"unique_dependencies_detected": len(deduped),
|
|
907
|
+
"unique_dependencies_checked": len(deduped),
|
|
908
|
+
"dependencies_capped": False,
|
|
909
|
+
"cves_found": 0,
|
|
910
|
+
"vulnerable_dependencies": 0,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if not deduped:
|
|
914
|
+
return [], [], stats
|
|
915
|
+
|
|
916
|
+
if len(deduped) > _MAX_DETAIL_QUERIES:
|
|
917
|
+
logger.warning(
|
|
918
|
+
f"[SCA] {len(deduped)} unique dependencies detected - capping OSV "
|
|
919
|
+
f"queries at {_MAX_DETAIL_QUERIES} to stay considerate of the free "
|
|
920
|
+
f"public API."
|
|
921
|
+
)
|
|
922
|
+
stats["dependencies_capped"] = True
|
|
923
|
+
deduped = deduped[:_MAX_DETAIL_QUERIES]
|
|
924
|
+
stats["unique_dependencies_checked"] = len(deduped)
|
|
925
|
+
|
|
926
|
+
# has_any_vuln_batch takes one ecosystem per call, so group first.
|
|
927
|
+
by_ecosystem: Dict[str, List[_WebDep]] = {}
|
|
928
|
+
for dep in deduped:
|
|
929
|
+
by_ecosystem.setdefault(dep.ecosystem, []).append(dep)
|
|
930
|
+
|
|
931
|
+
findings: List[Dict[str, Any]] = []
|
|
932
|
+
rule_defs: List[Dict[str, Any]] = []
|
|
933
|
+
vulnerable_coords: Set[str] = set()
|
|
934
|
+
|
|
935
|
+
for ecosystem, eco_deps in by_ecosystem.items():
|
|
936
|
+
pkg_version_pairs = [(d.name, d.version) for d in eco_deps]
|
|
937
|
+
has_vuln = client.has_any_vuln_batch(pkg_version_pairs, ecosystem=ecosystem)
|
|
938
|
+
|
|
939
|
+
for dep in eco_deps:
|
|
940
|
+
if not dep.version:
|
|
941
|
+
continue
|
|
942
|
+
key = (dep.name, dep.version)
|
|
943
|
+
if not has_vuln.get(key, False):
|
|
944
|
+
continue
|
|
945
|
+
|
|
946
|
+
osv_findings = client.query_dict(dep.name, dep.version, ecosystem=ecosystem)
|
|
947
|
+
for osv_finding in osv_findings:
|
|
948
|
+
finding = _finding_from_osv(dep, osv_finding)
|
|
949
|
+
findings.append(finding)
|
|
950
|
+
rule_defs.append(_rule_def_from_finding(finding))
|
|
951
|
+
stats["cves_found"] += 1
|
|
952
|
+
vulnerable_coords.add(f"{dep.ecosystem}:{dep.name}")
|
|
953
|
+
|
|
954
|
+
stats["vulnerable_dependencies"] = len(vulnerable_coords)
|
|
955
|
+
return findings, rule_defs, stats
|