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
@@ -0,0 +1,688 @@
1
+ """Dependency supply-chain risk beyond CVEs (#247).
2
+
3
+ Runs after the SBOM pass over the manifests, lockfiles and package-manager
4
+ configs already in the repo and emits a ``SupplyChainIssue`` per risky
5
+ pattern. Entirely offline and deterministic: no registry lookups (the
6
+ typosquat list is bundled in ``popular_packages``), so it is safe in the
7
+ default scan.
8
+
9
+ Kinds:
10
+
11
+ - **dependency_confusion** — an internal-looking package name (configured
12
+ ``internal_prefixes`` in ``.attackmap.yaml``, the repo's own npm scope as an
13
+ unscoped prefix, or ``internal-``/``-internal``/``private-``/``corp-``)
14
+ resolved through a setup that also consults the public registry: pip
15
+ ``--extra-index-url`` / Poetry ``supplemental``/``secondary`` sources, or
16
+ npm without a scoped ``@org:registry=``. ``--extra-index-url`` on its own is
17
+ reported low: any private name behind it can be registered on PyPI.
18
+ - **typosquat_candidate** — a direct npm/PyPI dependency one edit
19
+ (Damerau-Levenshtein ≤ 1) away from a popular package, not itself popular.
20
+ - **mutable_vcs_dependency** — ``git+…`` / ``github:user/repo`` / Cargo
21
+ ``git =`` without a full commit SHA, a tarball URL without a hash, a Go
22
+ ``replace`` to a fork or to a path outside the repo.
23
+ - **install_script** — a direct npm dependency with an install hook
24
+ (``hasInstallScript`` in ``package-lock.json``), or the repo's own
25
+ ``preinstall``/``install``/``postinstall``/``prepare`` piping ``curl`` to a
26
+ shell.
27
+ - **unlocked_manifest** — an npm/Composer/Pipenv manifest with dependencies
28
+ and no lockfile, or a ``package-lock.json`` entry with ``resolved`` but no
29
+ ``integrity``.
30
+ - **insecure_registry** — an ``http://`` registry / index URL, npm/yarn
31
+ ``strict-ssl=false``, or pip ``--trusted-host``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import configparser
37
+ import json
38
+ import re
39
+ import tomllib
40
+ from pathlib import Path
41
+
42
+ from .models import DependencyHint, SupplyChainIssue
43
+ from .popular_packages import BY_ECOSYSTEM
44
+ from .safe_fs import is_oversized, is_unsafe_link, walk_repo
45
+ from .srcpaths import is_test_file, split_lines
46
+
47
+ _MAX_DEPTH = 4
48
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", "dist", "build", ".venv", "venv", "target", "vendor"}
49
+ _SHA40 = re.compile(r"^[0-9a-f]{40}$")
50
+ _NPM_LOCKS = ("package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock")
51
+ _PY_LOCKS = ("poetry.lock", "uv.lock", "pdm.lock", "Pipfile.lock", "pylock.toml")
52
+ _PUBLIC_NPM = ("registry.npmjs.org", "registry.yarnpkg.com")
53
+ _GENERIC_INTERNAL_PREFIXES = ("internal-", "private-", "corp-")
54
+ _GENERIC_INTERNAL_SUFFIXES = ("-internal", "-private")
55
+ _INSTALL_HOOKS = ("preinstall", "install", "postinstall", "prepare")
56
+ _SHELLS = {"sh", "bash", "zsh", "dash", "node", "python", "python3"}
57
+ _TYPOSQUAT_MIN_LEN = 5
58
+ _URL_ARCHIVE = (".tar.gz", ".tgz", ".zip", ".whl", ".tar.bz2")
59
+ # `name @ url` PEP 508 direct reference.
60
+ _PEP508_URL_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]{0,200})\s*(?:\[[^\]]{0,200}\]\s*)?@\s*(\S{1,2000})")
61
+ # npm `user/repo` GitHub shorthand.
62
+ _NPM_GH_SHORTHAND_RE = re.compile(r"^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}(?:#\S{0,200})?$")
63
+ _GO_REPLACE_RE = re.compile(r"^\s*(?:replace\s+)?(\S{1,500})(?:\s+\S{1,100})?\s+=>\s+(\S{1,500})(?:\s+(\S{1,100}))?\s*$")
64
+
65
+
66
+ def _pep503(name: str) -> str:
67
+ return re.sub(r"[-_.]+", "-", name).lower()
68
+
69
+
70
+ def _line_of(text: str, needle: str) -> int | None:
71
+ for idx, line in enumerate(split_lines(text), start=1):
72
+ if needle in line:
73
+ return idx
74
+ return None
75
+
76
+
77
+ def _is_local_url(url: str) -> bool:
78
+ host = url.split("://", 1)[-1].split("/", 1)[0].split("@")[-1].split(":")[0].lower()
79
+ return host in {"localhost", "127.0.0.1", "::1", "[::1]"} or host.endswith(".localhost")
80
+
81
+
82
+ class _Collector:
83
+ def __init__(self, root: Path) -> None:
84
+ self.root = root
85
+ self.issues: list[SupplyChainIssue] = []
86
+ self._seen: set[tuple] = set()
87
+
88
+ def add(self, kind: str, file: str, severity: str, evidence: str, *,
89
+ package: str | None = None, ecosystem: str | None = None, line: int | None = None) -> None:
90
+ key = (kind, file, package, line, evidence)
91
+ if key in self._seen:
92
+ return
93
+ self._seen.add(key)
94
+ self.issues.append(SupplyChainIssue(
95
+ kind=kind, # type: ignore[arg-type]
96
+ package=package,
97
+ ecosystem=ecosystem,
98
+ file=file,
99
+ line=line,
100
+ evidence_text=evidence,
101
+ severity=severity, # type: ignore[arg-type]
102
+ ))
103
+
104
+
105
+ def _iter_files(root: Path):
106
+ for path in walk_repo(root, prune=lambda name: name in _SKIP_DIRS):
107
+ rel = path.relative_to(root)
108
+ if len(rel.parts) > _MAX_DEPTH or is_unsafe_link(root, path) or is_oversized(path):
109
+ continue
110
+ # Test fixtures declare deliberately loose or fake manifests; like the
111
+ # other heuristic passes, skip them (ATTACKMAP_INCLUDE_TESTS opts in).
112
+ if is_test_file(rel.as_posix()):
113
+ continue
114
+ yield path, rel.as_posix()
115
+
116
+
117
+ def _read(path: Path) -> str | None:
118
+ try:
119
+ return path.read_text(encoding="utf-8", errors="replace")
120
+ except OSError:
121
+ return None
122
+
123
+
124
+ def _has_lock(root: Path, directory: Path, names: tuple[str, ...]) -> bool:
125
+ """A lockfile in ``directory`` or an ancestor up to ``root`` (workspaces
126
+ keep one lockfile at the top)."""
127
+ current = directory
128
+ while True:
129
+ if any((current / n).is_file() for n in names):
130
+ return True
131
+ if current == root or root not in current.parents:
132
+ return False
133
+ current = current.parent
134
+
135
+
136
+ def _load_internal_prefixes(root: Path) -> list[str]:
137
+ config = root / ".attackmap.yaml"
138
+ if not config.is_file() or is_unsafe_link(root, config) or is_oversized(config):
139
+ return []
140
+ try:
141
+ import yaml # type: ignore[import-untyped]
142
+
143
+ data = yaml.safe_load(config.read_text(encoding="utf-8"))
144
+ except Exception: # noqa: BLE001 — a bad config must not sink the scan
145
+ return []
146
+ prefixes = data.get("internal_prefixes") if isinstance(data, dict) else None
147
+ if not isinstance(prefixes, list):
148
+ return []
149
+ return [str(p).lower() for p in prefixes if isinstance(p, str) and p.strip()]
150
+
151
+
152
+ # --- Public entrypoint -----------------------------------------------------
153
+
154
+
155
+ def scan_supply_chain(root: str | Path, dependencies: list[DependencyHint] | None = None) -> list[SupplyChainIssue]:
156
+ """Offline supply-chain risk pass. ``dependencies`` are the SBOM hints
157
+ (``analyze_sbom``); when omitted they are computed."""
158
+ root_path = Path(root).resolve()
159
+ if not root_path.is_dir():
160
+ return []
161
+ if dependencies is None:
162
+ from .sbom import analyze_sbom
163
+
164
+ dependencies = analyze_sbom(root_path)
165
+ out = _Collector(root_path)
166
+ ctx = _RegistryContext()
167
+
168
+ for path, rel in _iter_files(root_path):
169
+ name = path.name
170
+ if name == ".npmrc":
171
+ _scan_npmrc(path, rel, out, ctx)
172
+ elif name in {".yarnrc", ".yarnrc.yml"}:
173
+ _scan_yarnrc(path, rel, out)
174
+ elif name in {"pip.conf", "pip.ini"}:
175
+ _scan_pip_conf(path, rel, out, ctx)
176
+ elif name.endswith(".txt") and ("requirements" in name or name == "constraints.txt"):
177
+ _scan_requirements(path, rel, out, ctx)
178
+ elif name == "pyproject.toml":
179
+ _scan_pyproject(path, rel, out, ctx)
180
+ elif name == "package.json":
181
+ _scan_package_json(root_path, path, rel, out, ctx)
182
+ elif name == "package-lock.json":
183
+ _scan_package_lock(path, rel, out)
184
+ elif name == "go.mod":
185
+ _scan_go_mod(root_path, path, rel, out)
186
+ elif name == "Cargo.toml":
187
+ _scan_cargo_toml(root_path, path, rel, out)
188
+ elif name == "composer.json":
189
+ _scan_unlocked_json(root_path, path, rel, out, ("require", "require-dev"), ("composer.lock",), "composer")
190
+ elif name == "Pipfile":
191
+ if not (path.parent / "Pipfile.lock").is_file():
192
+ out.add("unlocked_manifest", rel, "low", "Pipfile without Pipfile.lock — installs resolve to whatever is newest", ecosystem="pypi")
193
+
194
+ production = [d for d in dependencies if not is_test_file(d.file)]
195
+ _check_confusion(production, _load_internal_prefixes(root_path), ctx, out)
196
+ _check_typosquats(production, out)
197
+ return out.issues
198
+
199
+
200
+ class _RegistryContext:
201
+ """Facts gathered from registry configs that the confusion check needs."""
202
+
203
+ def __init__(self) -> None:
204
+ self.pip_extra_index: list[tuple[str, int | None]] = [] # (file, line)
205
+ self.npm_private_registry = False
206
+ self.npm_scoped_registries: set[str] = set()
207
+ self.npm_own_scopes: set[str] = set()
208
+
209
+
210
+ # --- Registry configs ------------------------------------------------------
211
+
212
+
213
+ def _check_registry_url(url: str, rel: str, line: int | None, out: _Collector, eco: str) -> None:
214
+ if url.lower().startswith("http://") and not _is_local_url(url):
215
+ out.add("insecure_registry", rel, "high",
216
+ f"package index over plain http: {url} — a network attacker can swap packages in transit",
217
+ ecosystem=eco, line=line)
218
+
219
+
220
+ def _scan_npmrc(path: Path, rel: str, out: _Collector, ctx: _RegistryContext) -> None:
221
+ text = _read(path)
222
+ if text is None:
223
+ return
224
+ for lineno, raw in enumerate(split_lines(text), start=1):
225
+ line = raw.strip()
226
+ if not line or line.startswith(("#", ";")) or "=" not in line:
227
+ continue
228
+ key, _, value = (part.strip() for part in line.partition("="))
229
+ key_l = key.lower()
230
+ if key_l == "registry":
231
+ _check_registry_url(value, rel, lineno, out, "npm")
232
+ if not any(p in value for p in _PUBLIC_NPM):
233
+ ctx.npm_private_registry = True
234
+ elif key_l.startswith("@") and key_l.endswith(":registry"):
235
+ ctx.npm_scoped_registries.add(key_l.split(":", 1)[0][1:])
236
+ _check_registry_url(value, rel, lineno, out, "npm")
237
+ elif key_l == "strict-ssl" and value.lower() == "false":
238
+ out.add("insecure_registry", rel, "medium", "strict-ssl=false disables TLS verification for the npm registry",
239
+ ecosystem="npm", line=lineno)
240
+
241
+
242
+ def _scan_yarnrc(path: Path, rel: str, out: _Collector) -> None:
243
+ text = _read(path)
244
+ if text is None:
245
+ return
246
+ for lineno, raw in enumerate(split_lines(text), start=1):
247
+ line = raw.strip().replace('"', "").replace("'", "")
248
+ lowered = line.lower()
249
+ if lowered.startswith(("npmregistryserver:", "registry ", "registry:")):
250
+ url = line.split(":", 1)[1].strip() if lowered.startswith("npm") else line.split(None, 1)[-1].lstrip(":").strip()
251
+ _check_registry_url(url, rel, lineno, out, "npm")
252
+ elif lowered.replace(" ", "") in {"enablestrictssl:false", "strict-sslfalse"}:
253
+ out.add("insecure_registry", rel, "medium", f"{line} disables TLS verification for the registry",
254
+ ecosystem="npm", line=lineno)
255
+
256
+
257
+ def _scan_pip_conf(path: Path, rel: str, out: _Collector, ctx: _RegistryContext) -> None:
258
+ text = _read(path)
259
+ if text is None:
260
+ return
261
+ parser = configparser.ConfigParser(interpolation=None, strict=False)
262
+ try:
263
+ parser.read_string(text)
264
+ except configparser.Error:
265
+ return
266
+ for section in parser.sections():
267
+ for key, value in parser.items(section):
268
+ key = key.replace("_", "-")
269
+ for url in value.split():
270
+ if key in {"index-url", "extra-index-url"}:
271
+ line = _line_of(text, url)
272
+ _check_registry_url(url, rel, line, out, "pypi")
273
+ if key == "extra-index-url":
274
+ ctx.pip_extra_index.append((rel, line))
275
+ elif key == "trusted-host":
276
+ out.add("insecure_registry", rel, "medium",
277
+ f"trusted-host {url} skips TLS verification for that index",
278
+ ecosystem="pypi", line=_line_of(text, url))
279
+
280
+
281
+ # --- Python ----------------------------------------------------------------
282
+
283
+
284
+ def _vcs_ref(url: str) -> str | None:
285
+ """The ref after ``@`` in a pip VCS URL (``git+https://h/o/r.git@<ref>``)."""
286
+ base = url.split("#", 1)[0]
287
+ after_scheme = base.split("://", 1)[-1]
288
+ if "@" not in after_scheme:
289
+ return None
290
+ ref = after_scheme.rsplit("@", 1)[1]
291
+ return None if "/" in ref else ref # an `@` in the host part is userinfo
292
+
293
+
294
+ def _check_py_url(name: str | None, url: str, line_text: str, rel: str, lineno: int | None,
295
+ out: _Collector, locked: bool) -> None:
296
+ lowered = url.lower()
297
+ pkg = name or url
298
+ if lowered.startswith(("git+", "hg+", "svn+", "bzr+")):
299
+ ref = _vcs_ref(url)
300
+ if ref is None or not _SHA40.match(ref):
301
+ what = f"@{ref}" if ref else "no ref"
302
+ out.add("mutable_vcs_dependency", rel, "low" if locked else "medium",
303
+ f"{pkg}: VCS dependency pinned to {what}, not a commit SHA — the remote can change what installs: {url}",
304
+ package=name, ecosystem="pypi", line=lineno)
305
+ elif lowered.startswith(("http://", "https://")) and lowered.split("#", 1)[0].endswith(_URL_ARCHIVE):
306
+ if "sha256=" not in lowered and "--hash=" not in line_text:
307
+ out.add("mutable_vcs_dependency", rel, "low" if locked else "medium",
308
+ f"{pkg}: archive URL dependency without a hash (#sha256= / --hash=): {url}",
309
+ package=name, ecosystem="pypi", line=lineno)
310
+
311
+
312
+ def _scan_requirements(path: Path, rel: str, out: _Collector, ctx: _RegistryContext) -> None:
313
+ text = _read(path)
314
+ if text is None:
315
+ return
316
+ for lineno, raw in enumerate(split_lines(text), start=1):
317
+ line = re.sub(r"(^|\s)#.*$", "", raw).strip()
318
+ if not line:
319
+ continue
320
+ tokens = line.split()
321
+ head = tokens[0]
322
+ opt, _, inline = head.partition("=")
323
+ value = inline or (tokens[1] if len(tokens) > 1 else "")
324
+ if opt in {"--index-url", "-i", "--extra-index-url"}:
325
+ _check_registry_url(value, rel, lineno, out, "pypi")
326
+ if opt == "--extra-index-url":
327
+ ctx.pip_extra_index.append((rel, lineno))
328
+ continue
329
+ if opt == "--trusted-host":
330
+ out.add("insecure_registry", rel, "medium", f"--trusted-host {value} skips TLS verification for that index",
331
+ ecosystem="pypi", line=lineno)
332
+ continue
333
+ if head in {"-e", "--editable"} and len(tokens) > 1:
334
+ _check_py_url(None, tokens[1], line, rel, lineno, out, locked=False)
335
+ continue
336
+ if head.startswith("-"):
337
+ continue
338
+ match = _PEP508_URL_RE.match(line)
339
+ if match:
340
+ _check_py_url(match.group(1), match.group(2), line, rel, lineno, out, locked=False)
341
+ elif "://" in head:
342
+ _check_py_url(None, head, line, rel, lineno, out, locked=False)
343
+
344
+
345
+ def _scan_pyproject(path: Path, rel: str, out: _Collector, ctx: _RegistryContext) -> None:
346
+ text = _read(path)
347
+ if text is None:
348
+ return
349
+ try:
350
+ data = tomllib.loads(text)
351
+ except (tomllib.TOMLDecodeError, ValueError):
352
+ return
353
+ locked = _has_lock(path.parent, path.parent, _PY_LOCKS)
354
+ project = data.get("project") if isinstance(data.get("project"), dict) else {}
355
+ specs: list[str] = list(project.get("dependencies") or [])
356
+ optional = project.get("optional-dependencies")
357
+ if isinstance(optional, dict):
358
+ for group in optional.values():
359
+ if isinstance(group, list):
360
+ specs.extend(group)
361
+ for spec in specs:
362
+ if not isinstance(spec, str):
363
+ continue
364
+ match = _PEP508_URL_RE.match(spec)
365
+ if match:
366
+ _check_py_url(match.group(1), match.group(2), spec, rel, _line_of(text, spec[:60]), out, locked)
367
+
368
+ tool = data.get("tool") if isinstance(data.get("tool"), dict) else {}
369
+ poetry = tool.get("poetry") if isinstance(tool.get("poetry"), dict) else {}
370
+ dep_tables = [poetry.get("dependencies"), poetry.get("dev-dependencies")]
371
+ groups = poetry.get("group")
372
+ if isinstance(groups, dict):
373
+ dep_tables.extend(g.get("dependencies") for g in groups.values() if isinstance(g, dict))
374
+ for table in dep_tables:
375
+ if not isinstance(table, dict):
376
+ continue
377
+ for name, spec in table.items():
378
+ if isinstance(spec, dict) and isinstance(spec.get("git"), str):
379
+ rev = str(spec.get("rev", ""))
380
+ if not _SHA40.match(rev):
381
+ ref = spec.get("rev") or spec.get("tag") or spec.get("branch") or "default branch"
382
+ out.add("mutable_vcs_dependency", rel, "low" if locked else "medium",
383
+ f"{name}: git dependency on {ref}, not a commit SHA: {spec['git']}",
384
+ package=str(name), ecosystem="pypi", line=_line_of(text, spec["git"]))
385
+ sources = poetry.get("source")
386
+ if isinstance(sources, list):
387
+ for src in sources:
388
+ if not isinstance(src, dict):
389
+ continue
390
+ url = str(src.get("url", ""))
391
+ _check_registry_url(url, rel, _line_of(text, url) if url else None, out, "pypi")
392
+ if str(src.get("priority", "")).lower() in {"supplemental", "secondary"} or src.get("secondary") is True:
393
+ ctx.pip_extra_index.append((rel, _line_of(text, url) if url else None))
394
+ uv = tool.get("uv") if isinstance(tool.get("uv"), dict) else {}
395
+ uv_sources = uv.get("sources")
396
+ if isinstance(uv_sources, dict):
397
+ for name, spec in uv_sources.items():
398
+ if isinstance(spec, dict) and isinstance(spec.get("git"), str) and not _SHA40.match(str(spec.get("rev", ""))):
399
+ ref = spec.get("rev") or spec.get("tag") or spec.get("branch") or "default branch"
400
+ out.add("mutable_vcs_dependency", rel, "low" if locked else "medium",
401
+ f"{name}: git source on {ref}, not a commit SHA: {spec['git']}",
402
+ package=str(name), ecosystem="pypi", line=_line_of(text, spec["git"]))
403
+ indexes = uv.get("index")
404
+ if isinstance(indexes, list):
405
+ for idx in indexes:
406
+ if isinstance(idx, dict) and isinstance(idx.get("url"), str):
407
+ _check_registry_url(idx["url"], rel, _line_of(text, idx["url"]), out, "pypi")
408
+
409
+
410
+ # --- npm -------------------------------------------------------------------
411
+
412
+
413
+ def _npm_mutable(spec: str) -> str | None:
414
+ """Why an npm dependency spec is mutable, or None."""
415
+ lowered = spec.lower()
416
+ if lowered.startswith(("file:", "link:", "workspace:", "npm:", "portal:", "patch:")):
417
+ return None
418
+ is_git = lowered.startswith(("git+", "git://", "github:", "gitlab:", "bitbucket:", "gist:")) or (
419
+ not lowered.startswith("@") and "://" not in lowered and _NPM_GH_SHORTHAND_RE.match(spec) is not None
420
+ )
421
+ if is_git:
422
+ ref = spec.split("#", 1)[1] if "#" in spec else ""
423
+ return None if _SHA40.match(ref) else f"git dependency on {('#' + ref) if ref else 'the default branch'}, not a commit SHA"
424
+ if lowered.startswith(("http://", "https://")):
425
+ return "tarball URL dependency (no integrity hash in package.json)"
426
+ return None
427
+
428
+
429
+ def _scan_package_json(root: Path, path: Path, rel: str, out: _Collector, ctx: _RegistryContext) -> None:
430
+ text = _read(path)
431
+ if text is None:
432
+ return
433
+ try:
434
+ data = json.loads(text)
435
+ except ValueError:
436
+ return
437
+ if not isinstance(data, dict):
438
+ return
439
+ own = data.get("name")
440
+ if isinstance(own, str) and own.startswith("@") and "/" in own:
441
+ ctx.npm_own_scopes.add(own[1:].split("/", 1)[0].lower())
442
+ locked = _has_lock(root, path.parent, _NPM_LOCKS)
443
+ has_deps = False
444
+ for section in ("dependencies", "devDependencies", "optionalDependencies"):
445
+ block = data.get(section)
446
+ if not isinstance(block, dict):
447
+ continue
448
+ for name, spec in block.items():
449
+ has_deps = True
450
+ if not isinstance(spec, str):
451
+ continue
452
+ why = _npm_mutable(spec)
453
+ if why:
454
+ out.add("mutable_vcs_dependency", rel, "low" if locked else "medium", f"{name}: {why}: {spec}",
455
+ package=str(name), ecosystem="npm", line=_line_of(text, f'"{name}"'))
456
+ if has_deps and not locked:
457
+ out.add("unlocked_manifest", rel, "low",
458
+ "package.json declares dependencies but no lockfile is committed — each install resolves the newest matching versions",
459
+ ecosystem="npm")
460
+ scripts = data.get("scripts")
461
+ if isinstance(scripts, dict):
462
+ for hook in _INSTALL_HOOKS:
463
+ cmd = scripts.get(hook)
464
+ if isinstance(cmd, str) and _pipes_remote_to_shell(cmd):
465
+ out.add("install_script", rel, "high",
466
+ f"the repo's own {hook} script pipes a download into a shell: {cmd[:160]}",
467
+ ecosystem="npm", line=_line_of(text, f'"{hook}"'))
468
+
469
+
470
+ def _pipes_remote_to_shell(cmd: str) -> bool:
471
+ for segment in re.split(r"&&|;|\|\|", cmd):
472
+ if "|" not in segment or ("curl" not in segment and "wget" not in segment):
473
+ continue
474
+ head, *tails = segment.split("|")
475
+ if "curl" not in head and "wget" not in head:
476
+ continue
477
+ for tail in tails:
478
+ words = [w for w in tail.split() if w not in {"sudo", "-E", "env"}]
479
+ if words and words[0].rsplit("/", 1)[-1] in _SHELLS:
480
+ return True
481
+ return False
482
+
483
+
484
+ def _scan_package_lock(path: Path, rel: str, out: _Collector) -> None:
485
+ text = _read(path)
486
+ if text is None:
487
+ return
488
+ try:
489
+ data = json.loads(text)
490
+ except ValueError:
491
+ return
492
+ packages = data.get("packages") if isinstance(data, dict) else None
493
+ if not isinstance(packages, dict):
494
+ return
495
+ root_pkg = packages.get("") if isinstance(packages.get(""), dict) else {}
496
+ direct: set[str] = set()
497
+ for section in ("dependencies", "devDependencies", "optionalDependencies"):
498
+ if isinstance(root_pkg.get(section), dict):
499
+ direct.update(root_pkg[section])
500
+ missing_integrity: list[str] = []
501
+ for key, pkg in packages.items():
502
+ if not key.startswith("node_modules/") or not isinstance(pkg, dict):
503
+ continue
504
+ name = key[len("node_modules/"):]
505
+ if "node_modules/" in name:
506
+ continue # nested copy; the top-level entry carries the direct flag
507
+ if pkg.get("hasInstallScript") and name in direct:
508
+ out.add("install_script", rel, "medium",
509
+ f"{name}@{pkg.get('version', '?')} runs an install script (preinstall/install/postinstall) on npm install",
510
+ package=name, ecosystem="npm", line=_line_of(text, f'"{key}"'))
511
+ resolved = pkg.get("resolved")
512
+ if (isinstance(resolved, str) and resolved.startswith(("http://", "https://"))
513
+ and not pkg.get("integrity") and not pkg.get("link")):
514
+ missing_integrity.append(name)
515
+ if missing_integrity:
516
+ shown = ", ".join(sorted(missing_integrity)[:5])
517
+ more = f" (+{len(missing_integrity) - 5} more)" if len(missing_integrity) > 5 else ""
518
+ out.add("unlocked_manifest", rel, "medium",
519
+ f"{len(missing_integrity)} lockfile entr{'y' if len(missing_integrity) == 1 else 'ies'} resolved without an integrity hash: {shown}{more}",
520
+ ecosystem="npm")
521
+ for name, pkg in packages.items():
522
+ resolved = pkg.get("resolved") if isinstance(pkg, dict) else None
523
+ if isinstance(resolved, str) and resolved.startswith("http://") and not _is_local_url(resolved):
524
+ out.add("insecure_registry", rel, "high", f"lockfile resolves {name[len('node_modules/'):]} over plain http: {resolved}",
525
+ ecosystem="npm", line=_line_of(text, resolved))
526
+ break
527
+
528
+
529
+ # --- Go / Cargo / Composer -------------------------------------------------
530
+
531
+
532
+ def _scan_go_mod(root: Path, path: Path, rel: str, out: _Collector) -> None:
533
+ text = _read(path)
534
+ if text is None:
535
+ return
536
+ in_block = False
537
+ for lineno, raw in enumerate(split_lines(text), start=1):
538
+ line = raw.split("//", 1)[0].strip()
539
+ if line.startswith("replace") and line.endswith("("):
540
+ in_block = True
541
+ continue
542
+ if in_block and line == ")":
543
+ in_block = False
544
+ continue
545
+ if not (in_block or line.startswith("replace ")) or "=>" not in line:
546
+ continue
547
+ match = _GO_REPLACE_RE.match(line)
548
+ if not match:
549
+ continue
550
+ source, target = match.group(1), match.group(2)
551
+ if target.startswith((".", "/")):
552
+ resolved = (path.parent / target).resolve()
553
+ if resolved == root or root in resolved.parents:
554
+ continue # an in-repo module (monorepo) — part of this codebase
555
+ out.add("mutable_vcs_dependency", rel, "medium",
556
+ f"replace {source} => {target}: builds from a local path outside the repository, not a pinned module",
557
+ package=source, ecosystem="go", line=lineno)
558
+ elif target.split("/")[:3] != source.split("/")[:3]:
559
+ out.add("mutable_vcs_dependency", rel, "low",
560
+ f"replace {source} => {target} {match.group(3) or ''}".rstrip() + ": dependency redirected to a fork",
561
+ package=source, ecosystem="go", line=lineno)
562
+
563
+
564
+ def _scan_cargo_toml(root: Path, path: Path, rel: str, out: _Collector) -> None:
565
+ text = _read(path)
566
+ if text is None:
567
+ return
568
+ try:
569
+ data = tomllib.loads(text)
570
+ except (tomllib.TOMLDecodeError, ValueError):
571
+ return
572
+ locked = _has_lock(root, path.parent, ("Cargo.lock",))
573
+ tables = [data.get(k) for k in ("dependencies", "dev-dependencies", "build-dependencies")]
574
+ workspace = data.get("workspace")
575
+ if isinstance(workspace, dict):
576
+ tables.append(workspace.get("dependencies"))
577
+ for table in tables:
578
+ if not isinstance(table, dict):
579
+ continue
580
+ for name, spec in table.items():
581
+ if isinstance(spec, dict) and isinstance(spec.get("git"), str) and not _SHA40.match(str(spec.get("rev", ""))):
582
+ ref = spec.get("rev") or spec.get("tag") or spec.get("branch") or "default branch"
583
+ out.add("mutable_vcs_dependency", rel, "low" if locked else "medium",
584
+ f"{name}: git dependency on {ref}, not a commit SHA: {spec['git']}",
585
+ package=str(name), ecosystem="cargo", line=_line_of(text, spec["git"]))
586
+
587
+
588
+ def _scan_unlocked_json(root: Path, path: Path, rel: str, out: _Collector, sections: tuple[str, ...],
589
+ locks: tuple[str, ...], eco: str) -> None:
590
+ text = _read(path)
591
+ try:
592
+ data = json.loads(text) if text else None
593
+ except ValueError:
594
+ return
595
+ if not isinstance(data, dict) or _has_lock(root, path.parent, locks):
596
+ return
597
+ names = [n for s in sections if isinstance(data.get(s), dict) for n in data[s]]
598
+ if any(n.lower() != "php" and not n.lower().startswith(("ext-", "lib-")) for n in names):
599
+ out.add("unlocked_manifest", rel, "low",
600
+ f"{path.name} declares dependencies but no {locks[0]} is committed — each install resolves the newest matching versions",
601
+ ecosystem=eco)
602
+
603
+
604
+ # --- Name-based checks -----------------------------------------------------
605
+
606
+
607
+ def _internal_match(name: str, prefixes: list[str], own_scopes: set[str]) -> tuple[str, bool] | None:
608
+ """(reason, strong) when a name looks internal. ``strong`` = configured or
609
+ the repo's own scope (vs. a generic ``internal-`` pattern)."""
610
+ lowered = name.lower()
611
+ for prefix in prefixes:
612
+ if lowered.startswith(prefix):
613
+ return f"matches internal_prefixes entry '{prefix}'", True
614
+ for scope in sorted(own_scopes):
615
+ if lowered.startswith(f"{scope}-"):
616
+ return f"unscoped name with the repo's own '{scope}' prefix", True
617
+ if lowered.startswith(_GENERIC_INTERNAL_PREFIXES) or lowered.endswith(_GENERIC_INTERNAL_SUFFIXES):
618
+ return "internal-looking name", False
619
+ return None
620
+
621
+
622
+ def _check_confusion(deps: list[DependencyHint], prefixes: list[str], ctx: _RegistryContext, out: _Collector) -> None:
623
+ flagged_pypi = False
624
+ for dep in deps:
625
+ if not dep.direct:
626
+ continue
627
+ if dep.ecosystem == "pypi" and ctx.pip_extra_index:
628
+ hit = _internal_match(dep.name, prefixes, set())
629
+ if hit:
630
+ flagged_pypi = True
631
+ src, line = ctx.pip_extra_index[0]
632
+ out.add("dependency_confusion", dep.file, "high",
633
+ f"{dep.name} ({hit[0]}) is installed with --extra-index-url ({src}) — pip picks the highest "
634
+ "version across PyPI and the private index, so a public upload of this name wins",
635
+ package=dep.name, ecosystem="pypi", line=dep.line)
636
+ elif dep.ecosystem == "npm":
637
+ if dep.name.startswith("@"):
638
+ scope = dep.name[1:].split("/", 1)[0].lower()
639
+ if scope in ctx.npm_own_scopes and ctx.npm_private_registry and scope not in ctx.npm_scoped_registries:
640
+ out.add("dependency_confusion", dep.file, "medium",
641
+ f"{dep.name}: the repo's own @{scope} scope has no '@{scope}:registry=' in .npmrc, so it "
642
+ "resolves through the default registry",
643
+ package=dep.name, ecosystem="npm", line=dep.line)
644
+ continue
645
+ hit = _internal_match(dep.name, prefixes, ctx.npm_own_scopes)
646
+ if hit and (hit[1] or ctx.npm_private_registry):
647
+ out.add("dependency_confusion", dep.file, "high" if hit[1] else "medium",
648
+ f"{dep.name} ({hit[0]}) is an unscoped npm name — anyone can publish it on the public registry; "
649
+ "move it under a scope with an '@org:registry=' mapping",
650
+ package=dep.name, ecosystem="npm", line=dep.line)
651
+ if ctx.pip_extra_index and not flagged_pypi:
652
+ src, line = ctx.pip_extra_index[0]
653
+ out.add("dependency_confusion", src, "low",
654
+ "--extra-index-url (or a supplemental index) merges a private index with PyPI: any private package "
655
+ "name can be registered on PyPI with a higher version. Prefer a single proxying --index-url",
656
+ ecosystem="pypi", line=line)
657
+
658
+
659
+ def _within_one_edit(a: str, b: str) -> bool:
660
+ """Optimal-string-alignment (Damerau-Levenshtein) distance ≤ 1, a != b."""
661
+ la, lb = len(a), len(b)
662
+ if abs(la - lb) > 1 or a == b:
663
+ return False
664
+ if la == lb:
665
+ diffs = [i for i in range(la) if a[i] != b[i]]
666
+ if len(diffs) == 1:
667
+ return True
668
+ return len(diffs) == 2 and diffs[1] == diffs[0] + 1 and a[diffs[0]] == b[diffs[1]] and a[diffs[1]] == b[diffs[0]]
669
+ short, long_ = (a, b) if la < lb else (b, a)
670
+ i = 0
671
+ while i < len(short) and short[i] == long_[i]:
672
+ i += 1
673
+ return short[i:] == long_[i + 1:]
674
+
675
+
676
+ def _check_typosquats(deps: list[DependencyHint], out: _Collector) -> None:
677
+ for dep in deps:
678
+ popular = BY_ECOSYSTEM.get(dep.ecosystem)
679
+ if popular is None or not dep.direct:
680
+ continue
681
+ name = _pep503(dep.name) if dep.ecosystem == "pypi" else dep.name.lower()
682
+ if len(name) < _TYPOSQUAT_MIN_LEN or name in popular:
683
+ continue
684
+ near = sorted(p for p in popular if abs(len(p) - len(name)) <= 1 and _within_one_edit(name, p))
685
+ if near:
686
+ out.add("typosquat_candidate", dep.file, "medium",
687
+ f"{dep.name} is one edit away from the popular package {', '.join(near[:3])} — check it isn't a typosquat",
688
+ package=dep.name, ecosystem=dep.ecosystem, line=dep.line)