attackmap 0.6.4__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 (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/lockfiles.py ADDED
@@ -0,0 +1,521 @@
1
+ """Lockfile resolution for the SBOM/CVE pipeline (#143).
2
+
3
+ Manifests (``package.json``, ``pyproject.toml`` …) declare version *ranges*
4
+ and only direct dependencies. Lockfiles pin *exact* resolved versions and
5
+ record the full transitive tree — which is where most known-vulnerable
6
+ dependencies actually live. This module parses the common lockfiles into
7
+ ``DependencyHint`` records with ``resolved=True``, a ``direct`` flag, and a
8
+ ``via`` resolution path (``"express > body-parser > qs"``) reconstructed from
9
+ the dependency graph.
10
+
11
+ Supported lockfiles:
12
+ - package-lock.json (npm v1/v2/v3) → npm
13
+ - pnpm-lock.yaml (pnpm) → npm
14
+ - poetry.lock (Poetry) → pypi
15
+ - uv.lock (uv) → pypi
16
+ - Cargo.lock (Cargo) → cargo
17
+ - composer.lock (Composer) → composer (#255)
18
+ - packages.lock.json (NuGet) → nuget (#255)
19
+ - gradle.lockfile (Gradle) → maven (#255)
20
+
21
+ Go is intentionally NOT resolved from ``go.sum``: that file is a checksum
22
+ history and can retain modules absent from the current build list, so emitting
23
+ every line would over-report. ``go.mod`` already pins exact versions and flags
24
+ ``// indirect`` deps, so the manifest parser in ``sbom.py`` fully covers Go.
25
+
26
+ When a lockfile supersedes a range-only manifest, it does so only for the
27
+ manifest in the *same directory* — see ``analyze_sbom`` in ``sbom.py``.
28
+
29
+ Parsing is defensive: a malformed lockfile yields ``[]`` rather than raising.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import re
36
+ import tomllib
37
+ from collections import deque
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+
41
+ from .safe_fs import is_unsafe_link
42
+ from .models import DependencyHint
43
+
44
+ # Ecosystems whose manifests carry only ranges — a lockfile here supersedes
45
+ # the same-directory manifest.
46
+ SUPERSEDING_ECOSYSTEMS = frozenset({"npm", "pypi", "cargo", "composer"})
47
+
48
+ _LOCKFILE_NAMES = {
49
+ "package-lock.json",
50
+ "pnpm-lock.yaml",
51
+ "poetry.lock",
52
+ "uv.lock",
53
+ "Cargo.lock",
54
+ "composer.lock",
55
+ "packages.lock.json",
56
+ "gradle.lockfile",
57
+ }
58
+
59
+ _MAX_DEPTH = 4 # slightly deeper than manifests — lockfiles can nest
60
+
61
+
62
+ @dataclass
63
+ class _Graph:
64
+ """A resolved dependency graph for one lockfile.
65
+
66
+ ``instances`` maps each distinct installed package ``(name, version)`` to
67
+ its dev flag — keyed by (name, version) so multiple installed versions of
68
+ the same package are all preserved (npm hoisting). ``edges`` and ``roots``
69
+ are name-based (lockfile dependency references are by name), used only for
70
+ best-effort path reconstruction. When a parser can name the direct
71
+ dependencies explicitly it fills ``roots``; otherwise the resolver falls
72
+ back to "a name nothing depends on is a root".
73
+ """
74
+
75
+ ecosystem: str
76
+ instances: dict[tuple[str, str], bool] = field(default_factory=dict)
77
+ edges: dict[str, set[str]] = field(default_factory=dict)
78
+ roots: set[str] = field(default_factory=set)
79
+
80
+
81
+ def parse_lockfiles(root: str | Path) -> tuple[list[DependencyHint], set[tuple[str, str]]]:
82
+ """Walk ``root`` for lockfiles.
83
+
84
+ Returns ``(hints, superseded)`` where ``superseded`` is a set of
85
+ ``(ecosystem, directory)`` pairs — the range-only manifests in those exact
86
+ directories should be dropped in favour of the exact resolution.
87
+ """
88
+ root_path = Path(root).resolve()
89
+ if not root_path.exists() or not root_path.is_dir():
90
+ return [], set()
91
+
92
+ hints: list[DependencyHint] = []
93
+ superseded: set[tuple[str, str]] = set()
94
+ for lockfile in _iter_lockfiles(root_path):
95
+ rel = str(lockfile.relative_to(root_path)).replace("\\", "/")
96
+ directory = str(Path(rel).parent).replace("\\", "/")
97
+ try:
98
+ graph = _parse_lockfile(lockfile)
99
+ except (OSError, UnicodeDecodeError, ValueError, tomllib.TOMLDecodeError, json.JSONDecodeError):
100
+ continue
101
+ if graph is None or not graph.instances:
102
+ continue
103
+ file_hints = _graph_to_hints(graph, rel)
104
+ for hint in file_hints:
105
+ hint.source_analyzer = "sbom"
106
+ hints.extend(file_hints)
107
+ if graph.ecosystem in SUPERSEDING_ECOSYSTEMS:
108
+ superseded.add((graph.ecosystem, directory))
109
+ return hints, superseded
110
+
111
+
112
+ def _iter_lockfiles(root: Path):
113
+ def walk(directory: Path, depth: int):
114
+ try:
115
+ entries = sorted(directory.iterdir())
116
+ except (OSError, PermissionError):
117
+ return
118
+ for entry in entries:
119
+ if entry.name in {".git", "node_modules", "__pycache__", "dist", "build", ".venv", "venv", "target", "vendor"}:
120
+ continue
121
+ if is_unsafe_link(root, entry):
122
+ continue
123
+ if entry.is_dir():
124
+ if depth < _MAX_DEPTH:
125
+ yield from walk(entry, depth + 1)
126
+ elif entry.name in _LOCKFILE_NAMES:
127
+ yield entry
128
+
129
+ yield from walk(root, 1)
130
+
131
+
132
+ def _parse_lockfile(path: Path) -> _Graph | None:
133
+ name = path.name
134
+ if name == "package-lock.json":
135
+ return _parse_package_lock(path)
136
+ if name == "pnpm-lock.yaml":
137
+ return _parse_pnpm_lock(path)
138
+ if name == "poetry.lock":
139
+ return _parse_poetry_lock(path)
140
+ if name == "uv.lock":
141
+ return _parse_uv_lock(path)
142
+ if name == "Cargo.lock":
143
+ return _parse_cargo_lock(path)
144
+ if name == "composer.lock":
145
+ return _parse_composer_lock(path)
146
+ if name == "packages.lock.json":
147
+ return _parse_nuget_lock(path)
148
+ if name == "gradle.lockfile":
149
+ return _parse_gradle_lockfile(path)
150
+ return None
151
+
152
+
153
+ # --- Graph → hints ---------------------------------------------------------
154
+
155
+
156
+ def _graph_to_hints(graph: _Graph, rel: str) -> list[DependencyHint]:
157
+ """Reconstruct a resolution path for every installed instance and emit."""
158
+ names = {name for (name, _v) in graph.instances}
159
+ roots = graph.roots or _implicit_roots(graph, names)
160
+ paths = _shortest_paths(graph, roots, names)
161
+ hints: list[DependencyHint] = []
162
+ for (name, version), dev in sorted(graph.instances.items()):
163
+ path = paths.get(name)
164
+ is_direct = name in roots
165
+ via = " > ".join(path) if path is not None and len(path) > 1 else None
166
+ hints.append(
167
+ DependencyHint(
168
+ name=name,
169
+ version=version,
170
+ ecosystem=graph.ecosystem, # type: ignore[arg-type]
171
+ file=rel,
172
+ dev=dev,
173
+ resolved=True,
174
+ direct=is_direct,
175
+ via=via,
176
+ evidence_text=(f"{name}@{version}" if is_direct else f"transitive: {via or name}"),
177
+ )
178
+ )
179
+ return hints
180
+
181
+
182
+ def _implicit_roots(graph: _Graph, names: set[str]) -> set[str]:
183
+ """Roots = names nothing else depends on (reverse-graph heuristic)."""
184
+ depended_on: set[str] = set()
185
+ for children in graph.edges.values():
186
+ depended_on.update(children)
187
+ roots = {name for name in names if name not in depended_on}
188
+ # Degenerate cycle-only graph: treat every name as a root so nothing is
189
+ # silently dropped from the CVE scan.
190
+ return roots or set(names)
191
+
192
+
193
+ def _shortest_paths(graph: _Graph, roots: set[str], names: set[str]) -> dict[str, list[str]]:
194
+ """BFS from every root; record the shortest name-path to each name."""
195
+ # Roots seeded in sorted order: which root reaches a shared transitive
196
+ # first decides its `via` path, so set order made it vary per run (#353).
197
+ paths: dict[str, list[str]] = {r: [r] for r in sorted(roots) if r in names}
198
+ queue: deque[str] = deque(paths)
199
+ while queue:
200
+ current = queue.popleft()
201
+ for child in sorted(graph.edges.get(current, ())):
202
+ if child in paths or child not in names:
203
+ continue
204
+ paths[child] = paths[current] + [child]
205
+ queue.append(child)
206
+ return paths
207
+
208
+
209
+ # --- npm: package-lock.json ------------------------------------------------
210
+
211
+
212
+ def _parse_package_lock(path: Path) -> _Graph:
213
+ data = json.loads(path.read_text(encoding="utf-8"))
214
+ graph = _Graph(ecosystem="npm")
215
+ packages = data.get("packages")
216
+ if isinstance(packages, dict):
217
+ _parse_package_lock_v3(packages, graph)
218
+ else:
219
+ _parse_package_lock_v1(data.get("dependencies") or {}, graph)
220
+ return graph
221
+
222
+
223
+ def _pkg_name_from_path(pkg_path: str) -> str | None:
224
+ """`node_modules/a/node_modules/@scope/b` -> `@scope/b`."""
225
+ marker = "node_modules/"
226
+ idx = pkg_path.rfind(marker)
227
+ if idx == -1:
228
+ return None
229
+ return pkg_path[idx + len(marker):]
230
+
231
+
232
+ def _parse_package_lock_v3(packages: dict, graph: _Graph) -> None:
233
+ # The "" entry is the root project; its deps are the direct dependencies.
234
+ root_meta = packages.get("") or {}
235
+ for section in ("dependencies", "devDependencies"):
236
+ for dep in (root_meta.get(section) or {}):
237
+ graph.roots.add(dep)
238
+ for pkg_path, meta in packages.items():
239
+ if pkg_path == "" or not isinstance(meta, dict):
240
+ continue
241
+ name = _pkg_name_from_path(pkg_path)
242
+ version = meta.get("version")
243
+ if not name or not isinstance(version, str):
244
+ continue
245
+ dev = bool(meta.get("dev", False))
246
+ # Keyed by (name, version): a hoisted x@2 and a nested x@1 both survive.
247
+ graph.instances[(name, version)] = graph.instances.get((name, version), False) or dev
248
+ children = set()
249
+ for section in ("dependencies", "optionalDependencies", "peerDependencies"):
250
+ children.update((meta.get(section) or {}).keys())
251
+ if children:
252
+ graph.edges.setdefault(name, set()).update(children)
253
+
254
+
255
+ def _parse_package_lock_v1(deps: dict, graph: _Graph, *, is_root: bool = True) -> None:
256
+ for name, meta in deps.items():
257
+ if not isinstance(meta, dict):
258
+ continue
259
+ version = meta.get("version")
260
+ if isinstance(version, str):
261
+ dev = bool(meta.get("dev", False))
262
+ graph.instances[(name, version)] = graph.instances.get((name, version), False) or dev
263
+ if is_root:
264
+ graph.roots.add(name)
265
+ requires = meta.get("requires") or {}
266
+ if isinstance(requires, dict) and requires:
267
+ graph.edges.setdefault(name, set()).update(requires.keys())
268
+ nested = meta.get("dependencies")
269
+ if isinstance(nested, dict):
270
+ _parse_package_lock_v1(nested, graph, is_root=False)
271
+
272
+
273
+ # --- npm: pnpm-lock.yaml ---------------------------------------------------
274
+
275
+ # `[^@]*` already spans the scoped `/name` part; the old extra `(?:/[^@]+)?`
276
+ # made a long slash-heavy key backtrack quadratically (#236).
277
+ _PNPM_PKG_KEY_RE = re.compile(r"^/?(?P<name>@?[^@/][^@]*)@(?P<version>[^(]+)")
278
+
279
+
280
+ def _parse_pnpm_lock(path: Path) -> _Graph | None:
281
+ try:
282
+ import yaml
283
+ except ImportError: # pragma: no cover - PyYAML is a declared dependency
284
+ return None
285
+ # pnpm-lock is normally a single YAML document, but some files concatenate
286
+ # several with `---` separators (e.g. a merge artifact, or a tool that
287
+ # appends per-workspace lockfiles). Parse every document and merge them, and
288
+ # never let a malformed-YAML error abort the whole scan — a lockfile we can't
289
+ # read just yields no dependency hints.
290
+ try:
291
+ documents = [
292
+ doc
293
+ for doc in yaml.safe_load_all(path.read_text(encoding="utf-8"))
294
+ if isinstance(doc, dict)
295
+ ]
296
+ except yaml.YAMLError:
297
+ return None
298
+ if not documents:
299
+ return None
300
+ graph = _Graph(ecosystem="npm")
301
+ for data in documents:
302
+ _absorb_pnpm_document(data, graph)
303
+ return graph
304
+
305
+
306
+ def _absorb_pnpm_document(data: dict, graph: _Graph) -> None:
307
+ """Fold one parsed pnpm-lock YAML document into ``graph`` (roots + packages)."""
308
+ # Roots: importers.'.'.{dependencies,devDependencies} (v6+) or top-level.
309
+ importers = data.get("importers")
310
+ root_sections = []
311
+ if isinstance(importers, dict):
312
+ for imp in importers.values():
313
+ if isinstance(imp, dict):
314
+ root_sections.append(imp)
315
+ else:
316
+ root_sections.append(data)
317
+ for section in root_sections:
318
+ for key in ("dependencies", "devDependencies"):
319
+ deps = section.get(key)
320
+ if isinstance(deps, dict):
321
+ graph.roots.update(deps.keys())
322
+
323
+ packages = data.get("packages")
324
+ if not isinstance(packages, dict):
325
+ return
326
+ for raw_key, meta in packages.items():
327
+ m = _PNPM_PKG_KEY_RE.match(str(raw_key))
328
+ if not m:
329
+ continue
330
+ name = m.group("name")
331
+ version = m.group("version").strip()
332
+ dev = bool(meta.get("dev", False)) if isinstance(meta, dict) else False
333
+ graph.instances[(name, version)] = graph.instances.get((name, version), False) or dev
334
+ if isinstance(meta, dict):
335
+ children = set()
336
+ for key in ("dependencies", "optionalDependencies"):
337
+ deps = meta.get(key)
338
+ if isinstance(deps, dict):
339
+ children.update(deps.keys())
340
+ if children:
341
+ graph.edges.setdefault(name, set()).update(children)
342
+
343
+
344
+ # --- pypi: poetry.lock / uv.lock; cargo: Cargo.lock (all TOML [[package]]) --
345
+
346
+
347
+ def _parse_toml_packages(path: Path, ecosystem: str) -> _Graph:
348
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
349
+ graph = _Graph(ecosystem=ecosystem)
350
+ packages = data.get("package")
351
+ if not isinstance(packages, list):
352
+ return graph
353
+
354
+ # First pass: collect entries and identify local/workspace packages (the
355
+ # repo's own crates/projects). These are NOT third-party deps — their
356
+ # direct dependencies are the project's direct dependencies.
357
+ entries: list[tuple[str, str, bool, bool, set[str]]] = []
358
+ direct_names: set[str] = set()
359
+ for pkg in packages:
360
+ if not isinstance(pkg, dict):
361
+ continue
362
+ name = pkg.get("name")
363
+ version = pkg.get("version")
364
+ if not isinstance(name, str) or not isinstance(version, str):
365
+ continue
366
+ dev = pkg.get("category") == "dev" or "dev" in (pkg.get("groups") or [])
367
+ deps = _toml_dep_names(pkg.get("dependencies"))
368
+ is_local = _is_local_toml_pkg(pkg, ecosystem)
369
+ entries.append((name, version, bool(dev), is_local, deps))
370
+ if is_local:
371
+ direct_names.update(deps)
372
+
373
+ for name, version, dev, is_local, deps in entries:
374
+ if is_local:
375
+ continue # the project itself is not an SBOM entry
376
+ graph.instances[(name, version)] = graph.instances.get((name, version), False) or dev
377
+ if deps:
378
+ graph.edges.setdefault(name, set()).update(deps)
379
+ graph.roots = direct_names # empty → resolver falls back to reverse-graph
380
+ return graph
381
+
382
+
383
+ def _is_local_toml_pkg(pkg: dict, ecosystem: str) -> bool:
384
+ """True for the repo's own crate/project entry (not a third-party dep).
385
+
386
+ Cargo workspace members have no ``source``; uv's project package has an
387
+ editable/virtual source. Poetry never lists the root project in its lock.
388
+ """
389
+ source = pkg.get("source")
390
+ if ecosystem == "cargo":
391
+ return source is None
392
+ if ecosystem == "pypi": # uv.lock
393
+ return isinstance(source, dict) and ("editable" in source or "virtual" in source)
394
+ return False
395
+
396
+
397
+ def _toml_dep_names(deps) -> set[str]:
398
+ """Extract child names from a lockfile's `dependencies` value.
399
+
400
+ Handles poetry's ``{name = "spec"}`` table, uv/cargo's ``[{name=..}]`` and
401
+ cargo's ``["name version …"]`` string list."""
402
+ names: set[str] = set()
403
+ if isinstance(deps, dict):
404
+ names.update(deps.keys())
405
+ elif isinstance(deps, list):
406
+ for item in deps:
407
+ if isinstance(item, str):
408
+ names.add(item.split()[0]) # "name version source" → name
409
+ elif isinstance(item, dict) and isinstance(item.get("name"), str):
410
+ names.add(item["name"])
411
+ return names
412
+
413
+
414
+ def _parse_poetry_lock(path: Path) -> _Graph:
415
+ return _parse_toml_packages(path, "pypi")
416
+
417
+
418
+ def _parse_uv_lock(path: Path) -> _Graph:
419
+ return _parse_toml_packages(path, "pypi")
420
+
421
+
422
+ def _parse_cargo_lock(path: Path) -> _Graph:
423
+ return _parse_toml_packages(path, "cargo")
424
+
425
+
426
+ # --- composer: composer.lock (#255) ----------------------------------------
427
+
428
+
429
+ def _composer_platform(name: str) -> bool:
430
+ lowered = name.lower()
431
+ return lowered == "php" or lowered.startswith(("ext-", "lib-", "composer-"))
432
+
433
+
434
+ def _parse_composer_lock(path: Path) -> _Graph:
435
+ data = json.loads(path.read_text(encoding="utf-8"))
436
+ graph = _Graph(ecosystem="composer")
437
+ if not isinstance(data, dict):
438
+ return graph
439
+ for section, dev in (("packages", False), ("packages-dev", True)):
440
+ packages = data.get(section)
441
+ if not isinstance(packages, list):
442
+ continue
443
+ for pkg in packages:
444
+ if not isinstance(pkg, dict):
445
+ continue
446
+ name, version = pkg.get("name"), pkg.get("version")
447
+ if not isinstance(name, str) or not isinstance(version, str):
448
+ continue
449
+ # Packagist advisories use bare versions; composer.lock keeps the
450
+ # tag's `v` (symfony's `v5.4.1`).
451
+ version = version[1:] if version[:1] in {"v", "V"} and version[1:2].isdigit() else version
452
+ graph.instances[(name, version)] = graph.instances.get((name, version), False) or dev
453
+ requires = pkg.get("require")
454
+ if isinstance(requires, dict):
455
+ children = {str(c) for c in requires if not _composer_platform(str(c))}
456
+ if children:
457
+ graph.edges.setdefault(name, set()).update(children)
458
+ # Direct deps come from the sibling composer.json when present.
459
+ manifest = path.with_name("composer.json")
460
+ try:
461
+ manifest_data = json.loads(manifest.read_text(encoding="utf-8")) if manifest.is_file() else {}
462
+ except (OSError, UnicodeDecodeError, ValueError):
463
+ manifest_data = {}
464
+ if isinstance(manifest_data, dict):
465
+ for section in ("require", "require-dev"):
466
+ block = manifest_data.get(section)
467
+ if isinstance(block, dict):
468
+ graph.roots.update(str(n) for n in block if not _composer_platform(str(n)))
469
+ return graph
470
+
471
+
472
+ # --- nuget: packages.lock.json (#255) --------------------------------------
473
+
474
+
475
+ def _parse_nuget_lock(path: Path) -> _Graph:
476
+ data = json.loads(path.read_text(encoding="utf-8"))
477
+ graph = _Graph(ecosystem="nuget")
478
+ frameworks = data.get("dependencies") if isinstance(data, dict) else None
479
+ if not isinstance(frameworks, dict):
480
+ return graph
481
+ for packages in frameworks.values(): # one block per target framework
482
+ if not isinstance(packages, dict):
483
+ continue
484
+ for name, pkg in packages.items():
485
+ if not isinstance(pkg, dict):
486
+ continue
487
+ kind = str(pkg.get("type", ""))
488
+ version = pkg.get("resolved")
489
+ if kind == "Project" or not isinstance(version, str) or not version:
490
+ continue # a project reference is the repo's own code
491
+ graph.instances.setdefault((str(name), version), False)
492
+ if kind == "Direct":
493
+ graph.roots.add(str(name))
494
+ children = pkg.get("dependencies")
495
+ if isinstance(children, dict) and children:
496
+ graph.edges.setdefault(str(name), set()).update(str(c) for c in children)
497
+ return graph
498
+
499
+
500
+ # --- maven: gradle.lockfile (#255) -----------------------------------------
501
+
502
+
503
+ def _parse_gradle_lockfile(path: Path) -> _Graph:
504
+ """``group:artifact:version=conf1,conf2`` lines. Gradle's lockfile doesn't
505
+ record the graph, so every entry is treated as direct; one used only by
506
+ test configurations is marked dev."""
507
+ graph = _Graph(ecosystem="maven")
508
+ for raw in path.read_text(encoding="utf-8").splitlines():
509
+ line = raw.strip()
510
+ if not line or line.startswith("#") or line.startswith("empty="):
511
+ continue
512
+ coord, _, confs = line.partition("=")
513
+ parts = coord.split(":")
514
+ if len(parts) != 3 or not all(parts):
515
+ continue
516
+ name, version = f"{parts[0]}:{parts[1]}", parts[2]
517
+ conf_list = [c.strip() for c in confs.split(",") if c.strip()]
518
+ dev = bool(conf_list) and all(c.lower().startswith("test") for c in conf_list)
519
+ graph.instances[(name, version)] = graph.instances.get((name, version), True) and dev
520
+ graph.roots.add(name)
521
+ return graph