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
|
@@ -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)
|