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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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
|