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/sbom.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""SBOM inventory (slice 1 of #48).
|
|
2
|
+
|
|
3
|
+
Scans a repository for dependency manifests and emits a
|
|
4
|
+
``DependencyHint`` per declared third-party package. Direct dependencies
|
|
5
|
+
only — no lockfile resolution, no transitive walk. Version ranges are
|
|
6
|
+
kept verbatim (``^4.16.0``, ``>=2,<3``, etc.); consumers that need a
|
|
7
|
+
resolved version look at lockfiles (deferred slice).
|
|
8
|
+
|
|
9
|
+
Supported ecosystems:
|
|
10
|
+
- pypi pyproject.toml (PEP 621) + requirements.txt
|
|
11
|
+
- npm package.json (dependencies + devDependencies)
|
|
12
|
+
- go go.mod (require blocks)
|
|
13
|
+
- cargo Cargo.toml ([dependencies] + [dev-dependencies])
|
|
14
|
+
- composer composer.json (require + require-dev)
|
|
15
|
+
|
|
16
|
+
CVE lookup is intentionally NOT here — it's a separate ticket (see the
|
|
17
|
+
follow-up filed with the PR that lands this slice) because it requires
|
|
18
|
+
network I/O, on-disk caching, and rate limiting.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import tomllib
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .safe_fs import is_unsafe_link
|
|
29
|
+
from .lockfiles import parse_lockfiles
|
|
30
|
+
from .models import DependencyHint
|
|
31
|
+
from .srcpaths import split_lines
|
|
32
|
+
|
|
33
|
+
_MAX_DEPTH = 3
|
|
34
|
+
_SKIP_DIRS = {
|
|
35
|
+
".git",
|
|
36
|
+
"node_modules",
|
|
37
|
+
"__pycache__",
|
|
38
|
+
"dist",
|
|
39
|
+
"build",
|
|
40
|
+
".venv",
|
|
41
|
+
"venv",
|
|
42
|
+
"target",
|
|
43
|
+
"vendor",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def analyze_sbom(root: str | Path) -> list[DependencyHint]:
|
|
48
|
+
"""Walk ``root`` and return a deduped list of DependencyHint records.
|
|
49
|
+
|
|
50
|
+
Manifests contribute direct deps with verbatim version ranges. Lockfiles
|
|
51
|
+
(#143) contribute exact resolved versions and the transitive tree; where a
|
|
52
|
+
lockfile supersedes a range-only manifest (npm/pypi/cargo/composer) the manifest
|
|
53
|
+
hints for that ecosystem are dropped in favour of the exact resolution.
|
|
54
|
+
"""
|
|
55
|
+
root_path = Path(root).resolve()
|
|
56
|
+
if not root_path.exists() or not root_path.is_dir():
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
manifest_hints: list[DependencyHint] = []
|
|
60
|
+
for manifest in _iter_manifests(root_path):
|
|
61
|
+
rel = str(manifest.relative_to(root_path)).replace("\\", "/")
|
|
62
|
+
try:
|
|
63
|
+
manifest_hints.extend(_parse_manifest(manifest, rel))
|
|
64
|
+
except (OSError, UnicodeDecodeError, ValueError, tomllib.TOMLDecodeError, json.JSONDecodeError):
|
|
65
|
+
# A malformed manifest shouldn't sink the whole scan; skip it.
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
lock_hints, superseded = parse_lockfiles(root_path)
|
|
69
|
+
|
|
70
|
+
# Drop a range-only manifest hint only when a lockfile of the same
|
|
71
|
+
# ecosystem sits in the *same directory* — so a monorepo's lockfile in
|
|
72
|
+
# service A doesn't silently exclude service B's un-locked manifest (#143).
|
|
73
|
+
def _dir(rel: str) -> str:
|
|
74
|
+
return str(Path(rel).parent).replace("\\", "/")
|
|
75
|
+
|
|
76
|
+
kept_manifest = [
|
|
77
|
+
h for h in manifest_hints if (h.ecosystem, _dir(h.file)) not in superseded
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
hints = kept_manifest + lock_hints
|
|
81
|
+
for hint in hints:
|
|
82
|
+
hint.source_analyzer = "sbom"
|
|
83
|
+
|
|
84
|
+
return _dedup(hints)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _iter_manifests(root: Path):
|
|
88
|
+
"""Depth-capped walk yielding candidate manifest files by name."""
|
|
89
|
+
|
|
90
|
+
interesting = {
|
|
91
|
+
"pyproject.toml",
|
|
92
|
+
"requirements.txt",
|
|
93
|
+
"package.json",
|
|
94
|
+
"go.mod",
|
|
95
|
+
"Cargo.toml",
|
|
96
|
+
"composer.json",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def walk(directory: Path, depth: int):
|
|
100
|
+
try:
|
|
101
|
+
entries = sorted(directory.iterdir())
|
|
102
|
+
except (OSError, PermissionError):
|
|
103
|
+
return
|
|
104
|
+
for entry in entries:
|
|
105
|
+
if entry.name in _SKIP_DIRS or is_unsafe_link(root, entry):
|
|
106
|
+
continue
|
|
107
|
+
if entry.is_dir():
|
|
108
|
+
if depth < _MAX_DEPTH:
|
|
109
|
+
yield from walk(entry, depth + 1)
|
|
110
|
+
elif entry.name in interesting:
|
|
111
|
+
yield entry
|
|
112
|
+
|
|
113
|
+
yield from walk(root, 1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_manifest(path: Path, rel: str) -> list[DependencyHint]:
|
|
117
|
+
name = path.name
|
|
118
|
+
if name == "pyproject.toml":
|
|
119
|
+
return _parse_pyproject(path, rel)
|
|
120
|
+
if name == "requirements.txt":
|
|
121
|
+
return _parse_requirements(path, rel)
|
|
122
|
+
if name == "package.json":
|
|
123
|
+
return _parse_package_json(path, rel)
|
|
124
|
+
if name == "go.mod":
|
|
125
|
+
return _parse_go_mod(path, rel)
|
|
126
|
+
if name == "Cargo.toml":
|
|
127
|
+
return _parse_cargo_toml(path, rel)
|
|
128
|
+
if name == "composer.json":
|
|
129
|
+
return _parse_composer_json(path, rel)
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------- Python ---------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_REQUIREMENT_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_pyproject(path: Path, rel: str) -> list[DependencyHint]:
|
|
140
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
141
|
+
project = data.get("project") or {}
|
|
142
|
+
hints: list[DependencyHint] = []
|
|
143
|
+
for spec in project.get("dependencies") or []:
|
|
144
|
+
parsed = _parse_pep508(str(spec))
|
|
145
|
+
if parsed:
|
|
146
|
+
name, version = parsed
|
|
147
|
+
hints.append(DependencyHint(name=name, version=version, ecosystem="pypi", file=rel, evidence_text=str(spec)))
|
|
148
|
+
optional: dict = project.get("optional-dependencies") or {}
|
|
149
|
+
for group_name, specs in optional.items():
|
|
150
|
+
is_dev = group_name.lower() in {"dev", "test", "tests", "lint", "docs"}
|
|
151
|
+
for spec in specs or []:
|
|
152
|
+
parsed = _parse_pep508(str(spec))
|
|
153
|
+
if parsed:
|
|
154
|
+
name, version = parsed
|
|
155
|
+
hints.append(
|
|
156
|
+
DependencyHint(
|
|
157
|
+
name=name,
|
|
158
|
+
version=version,
|
|
159
|
+
ecosystem="pypi",
|
|
160
|
+
file=rel,
|
|
161
|
+
dev=is_dev,
|
|
162
|
+
evidence_text=f"[{group_name}] {spec}",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
# Poetry (still fairly common); tool.poetry.dependencies is a dict.
|
|
166
|
+
poetry = (data.get("tool") or {}).get("poetry") or {}
|
|
167
|
+
for spec_name, spec_value in (poetry.get("dependencies") or {}).items():
|
|
168
|
+
if spec_name.lower() == "python":
|
|
169
|
+
continue
|
|
170
|
+
version = _stringify_poetry_version(spec_value)
|
|
171
|
+
hints.append(
|
|
172
|
+
DependencyHint(
|
|
173
|
+
name=spec_name,
|
|
174
|
+
version=version,
|
|
175
|
+
ecosystem="pypi",
|
|
176
|
+
file=rel,
|
|
177
|
+
evidence_text=f"tool.poetry.dependencies.{spec_name} = {spec_value!r}",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
for spec_name, spec_value in (poetry.get("dev-dependencies") or {}).items():
|
|
181
|
+
version = _stringify_poetry_version(spec_value)
|
|
182
|
+
hints.append(
|
|
183
|
+
DependencyHint(
|
|
184
|
+
name=spec_name,
|
|
185
|
+
version=version,
|
|
186
|
+
ecosystem="pypi",
|
|
187
|
+
file=rel,
|
|
188
|
+
dev=True,
|
|
189
|
+
evidence_text=f"tool.poetry.dev-dependencies.{spec_name} = {spec_value!r}",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return hints
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_pep508(spec: str) -> tuple[str, str] | None:
|
|
196
|
+
"""PEP 508 spec → (name, version-or-marker-part)."""
|
|
197
|
+
stripped = spec.strip()
|
|
198
|
+
if not stripped or stripped.startswith("#"):
|
|
199
|
+
return None
|
|
200
|
+
match = _REQUIREMENT_NAME_RE.match(stripped)
|
|
201
|
+
if not match:
|
|
202
|
+
return None
|
|
203
|
+
name = match.group(1)
|
|
204
|
+
remainder = stripped[match.end():].strip()
|
|
205
|
+
# Cut off env markers (`; python_version < '3.10'`) — they aren't a version.
|
|
206
|
+
if ";" in remainder:
|
|
207
|
+
remainder = remainder.split(";", 1)[0].strip()
|
|
208
|
+
# Extras: `foo[bar]`
|
|
209
|
+
if remainder.startswith("["):
|
|
210
|
+
close = remainder.find("]")
|
|
211
|
+
if close != -1:
|
|
212
|
+
remainder = remainder[close + 1 :].strip()
|
|
213
|
+
version = remainder if remainder else "*"
|
|
214
|
+
return name, version
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _stringify_poetry_version(value: object) -> str:
|
|
218
|
+
if isinstance(value, str):
|
|
219
|
+
return value
|
|
220
|
+
if isinstance(value, dict):
|
|
221
|
+
return str(value.get("version") or "*")
|
|
222
|
+
return "*"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_requirements(path: Path, rel: str) -> list[DependencyHint]:
|
|
226
|
+
hints: list[DependencyHint] = []
|
|
227
|
+
for lineno, raw in enumerate(split_lines(path.read_text(encoding="utf-8")), start=1):
|
|
228
|
+
line = raw.split("#", 1)[0].strip()
|
|
229
|
+
if not line:
|
|
230
|
+
continue
|
|
231
|
+
if line.startswith("-"):
|
|
232
|
+
# -r / -e / -c / --index-url etc. — skip; not a dep declaration.
|
|
233
|
+
continue
|
|
234
|
+
if "://" in line.split(" @ ", 1)[0]:
|
|
235
|
+
# A bare VCS/archive URL (`git+https://…`) isn't a `name spec`
|
|
236
|
+
# line; parsing it would invent a dependency called "git" (#247).
|
|
237
|
+
continue
|
|
238
|
+
parsed = _parse_pep508(line)
|
|
239
|
+
if parsed:
|
|
240
|
+
name, version = parsed
|
|
241
|
+
hints.append(
|
|
242
|
+
DependencyHint(
|
|
243
|
+
name=name,
|
|
244
|
+
version=version,
|
|
245
|
+
ecosystem="pypi",
|
|
246
|
+
file=rel,
|
|
247
|
+
line=lineno,
|
|
248
|
+
evidence_text=line,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return hints
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------- Node -----------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_package_json(path: Path, rel: str) -> list[DependencyHint]:
|
|
258
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
259
|
+
hints: list[DependencyHint] = []
|
|
260
|
+
for section, is_dev in (("dependencies", False), ("devDependencies", True), ("peerDependencies", False), ("optionalDependencies", False)):
|
|
261
|
+
section_data = data.get(section) or {}
|
|
262
|
+
if not isinstance(section_data, dict):
|
|
263
|
+
continue
|
|
264
|
+
for name, version in section_data.items():
|
|
265
|
+
hints.append(
|
|
266
|
+
DependencyHint(
|
|
267
|
+
name=str(name),
|
|
268
|
+
version=str(version),
|
|
269
|
+
ecosystem="npm",
|
|
270
|
+
file=rel,
|
|
271
|
+
dev=is_dev,
|
|
272
|
+
evidence_text=f"{section}.{name} = {version!r}",
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
return hints
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------- Go -------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_GO_REQUIRE_LINE_RE = re.compile(
|
|
282
|
+
r"^\s*(?P<name>[^\s]+)\s+(?P<version>v[^\s]+)"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_go_mod(path: Path, rel: str) -> list[DependencyHint]:
|
|
287
|
+
"""Parse `require` directives in go.mod (both single-line and block form)."""
|
|
288
|
+
text = path.read_text(encoding="utf-8")
|
|
289
|
+
hints: list[DependencyHint] = []
|
|
290
|
+
in_block = False
|
|
291
|
+
for lineno, raw in enumerate(split_lines(text), start=1):
|
|
292
|
+
stripped = raw.strip()
|
|
293
|
+
if not stripped or stripped.startswith("//"):
|
|
294
|
+
continue
|
|
295
|
+
if stripped.startswith("require ("):
|
|
296
|
+
in_block = True
|
|
297
|
+
continue
|
|
298
|
+
if in_block and stripped == ")":
|
|
299
|
+
in_block = False
|
|
300
|
+
continue
|
|
301
|
+
if in_block:
|
|
302
|
+
body = stripped.split("//", 1)[0].strip()
|
|
303
|
+
m = _GO_REQUIRE_LINE_RE.match(body)
|
|
304
|
+
if m:
|
|
305
|
+
is_indirect = "// indirect" in raw
|
|
306
|
+
hints.append(
|
|
307
|
+
DependencyHint(
|
|
308
|
+
name=m.group("name"),
|
|
309
|
+
version=m.group("version"),
|
|
310
|
+
ecosystem="go",
|
|
311
|
+
file=rel,
|
|
312
|
+
line=lineno,
|
|
313
|
+
dev=is_indirect,
|
|
314
|
+
# go.mod pins exact versions and flags indirect
|
|
315
|
+
# (transitive) deps — treat it as a resolved lockfile.
|
|
316
|
+
resolved=True,
|
|
317
|
+
direct=not is_indirect,
|
|
318
|
+
evidence_text=raw.strip(),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
continue
|
|
322
|
+
if stripped.startswith("require "):
|
|
323
|
+
# single-line: `require module/name v1.2.3`
|
|
324
|
+
body = stripped[len("require "):].split("//", 1)[0].strip()
|
|
325
|
+
m = _GO_REQUIRE_LINE_RE.match(body)
|
|
326
|
+
if m:
|
|
327
|
+
is_indirect = "// indirect" in raw
|
|
328
|
+
hints.append(
|
|
329
|
+
DependencyHint(
|
|
330
|
+
name=m.group("name"),
|
|
331
|
+
version=m.group("version"),
|
|
332
|
+
ecosystem="go",
|
|
333
|
+
file=rel,
|
|
334
|
+
line=lineno,
|
|
335
|
+
dev=is_indirect,
|
|
336
|
+
resolved=True,
|
|
337
|
+
direct=not is_indirect,
|
|
338
|
+
evidence_text=raw.strip(),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
return hints
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------- Rust -----------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _parse_cargo_toml(path: Path, rel: str) -> list[DependencyHint]:
|
|
348
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
349
|
+
hints: list[DependencyHint] = []
|
|
350
|
+
for section, is_dev in (
|
|
351
|
+
("dependencies", False),
|
|
352
|
+
("dev-dependencies", True),
|
|
353
|
+
("build-dependencies", True),
|
|
354
|
+
):
|
|
355
|
+
section_data = data.get(section) or {}
|
|
356
|
+
if not isinstance(section_data, dict):
|
|
357
|
+
continue
|
|
358
|
+
for name, spec in section_data.items():
|
|
359
|
+
if isinstance(spec, str):
|
|
360
|
+
version = spec
|
|
361
|
+
elif isinstance(spec, dict):
|
|
362
|
+
version = str(spec.get("version") or "*")
|
|
363
|
+
else:
|
|
364
|
+
version = "*"
|
|
365
|
+
hints.append(
|
|
366
|
+
DependencyHint(
|
|
367
|
+
name=str(name),
|
|
368
|
+
version=version,
|
|
369
|
+
ecosystem="cargo",
|
|
370
|
+
file=rel,
|
|
371
|
+
dev=is_dev,
|
|
372
|
+
evidence_text=f"[{section}] {name} = {spec!r}",
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return hints
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------- PHP ------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _parse_composer_json(path: Path, rel: str) -> list[DependencyHint]:
|
|
382
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
383
|
+
hints: list[DependencyHint] = []
|
|
384
|
+
for section, is_dev in (("require", False), ("require-dev", True)):
|
|
385
|
+
section_data = data.get(section) or {}
|
|
386
|
+
if not isinstance(section_data, dict):
|
|
387
|
+
continue
|
|
388
|
+
for name, version in section_data.items():
|
|
389
|
+
# composer.json's own "php" / "ext-*" entries are runtime
|
|
390
|
+
# requirements, not third-party packages — skip.
|
|
391
|
+
if name.lower() == "php" or name.lower().startswith("ext-") or name.lower().startswith("lib-"):
|
|
392
|
+
continue
|
|
393
|
+
hints.append(
|
|
394
|
+
DependencyHint(
|
|
395
|
+
name=str(name),
|
|
396
|
+
version=str(version),
|
|
397
|
+
ecosystem="composer",
|
|
398
|
+
file=rel,
|
|
399
|
+
dev=is_dev,
|
|
400
|
+
evidence_text=f"{section}.{name} = {version!r}",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
return hints
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------- Dedup ----------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _dedup(hints: list[DependencyHint]) -> list[DependencyHint]:
|
|
410
|
+
seen: set[tuple[str, str, str, str, bool]] = set()
|
|
411
|
+
out: list[DependencyHint] = []
|
|
412
|
+
for h in hints:
|
|
413
|
+
key = (h.ecosystem, h.name, h.version, h.file, h.dev)
|
|
414
|
+
if key in seen:
|
|
415
|
+
continue
|
|
416
|
+
seen.add(key)
|
|
417
|
+
out.append(h)
|
|
418
|
+
return out
|