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.
Files changed (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. 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