attackmap-analyzer-cpp 0.2.0__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.
@@ -0,0 +1,524 @@
1
+ """Native dependency inventory: CMake, Conan and vcpkg (AttackMap#191).
2
+
3
+ Emits ``DependencyHint`` records so ``--cve`` reaches native dependencies,
4
+ plus ``SupplyChainIssue`` records for CMake downloads that aren't pinned.
5
+
6
+ - **vcpkg** (``vcpkg.json``): ``dependencies`` (strings or objects with
7
+ ``version>=``), ``overrides`` (exact pins, ``resolved=True``) and the
8
+ ``builtin-baseline`` (recorded in the evidence). ``host`` dependencies are
9
+ build tools (``dev=True``). Ecosystem ``vcpkg``.
10
+ - **Conan** (``conanfile.txt``, ``conanfile.py``, ``conan.lock``): ``[requires]``
11
+ / ``requires = ...`` / ``self.requires("zlib/1.3")``; tool, build and test
12
+ requirements are ``dev``. ``conan.lock`` entries are exact
13
+ (``resolved=True``). ``conanfile.py`` is read as text and never executed.
14
+ Ecosystem ``conan``.
15
+ - **CMake** (``CMakeLists.txt``, ``*.cmake``): ``FetchContent_Declare``,
16
+ ``ExternalProject_Add``, ``CPMAddPackage`` and ``find_package``. Ecosystem
17
+ ``cmake`` (inventory only: no OSV database). A git download whose
18
+ ``GIT_TAG`` is missing or names a branch, or an archive ``URL`` with no
19
+ ``URL_HASH``/``URL_MD5``, is a ``mutable_vcs_dependency`` (medium). A tag
20
+ or short SHA is low. A full 40-hex commit SHA isn't flagged.
21
+
22
+ Arguments that are CMake variables (``${X}``) are unresolved and never flagged.
23
+
24
+ This module is mirrored verbatim in attackmap-analyzer-c and
25
+ attackmap-analyzer-cpp; both may run on a mixed repo, and core's merge
26
+ dedupes identical records.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import ast
32
+ import bisect
33
+ import json
34
+ import re
35
+ from pathlib import Path, PurePosixPath
36
+
37
+ from attackmap.sdk import iter_repo_files, read_source, rel
38
+
39
+ try: # attackmap >= the release with the `cmake` ecosystem and SupplyChainIssue (AttackMap#191)
40
+ from attackmap.sdk import DEPENDENCY_ECOSYSTEMS, DependencyHint, SupplyChainIssue
41
+ except ImportError: # older core: no native SBOM
42
+ DependencyHint = SupplyChainIssue = None # type: ignore[assignment,misc]
43
+ DEPENDENCY_ECOSYSTEMS = ()
44
+
45
+ _MANIFEST_NAMES = {"CMakeLists.txt", "vcpkg.json", "conanfile.txt", "conanfile.py", "conan.lock"}
46
+ _MAX_ARGS_SPAN = 8000
47
+ _MAX_FILE_CHARS = 2_000_000
48
+
49
+ _CMAKE_QUOTED_BODY_RE = re.compile(r"(?:[^\"\\]|\\[\s\S])*")
50
+ _PAREN_RE = re.compile(r"[()]")
51
+ _CMAKE_COMMAND_RE = re.compile(
52
+ r"\b(FetchContent_Declare|ExternalProject_Add|CPMAddPackage|CPMFindPackage|find_package)\s*\(", re.IGNORECASE
53
+ )
54
+ _CMAKE_TOKEN_RE = re.compile(r"\"((?:[^\"\\]|\\[\s\S])*)\"|([^\s\"()]+)")
55
+ _SHA40_RE = re.compile(r"^[0-9a-fA-F]{40}$")
56
+ _HEX_RE = re.compile(r"^[0-9a-fA-F]{7,39}$")
57
+ _BRANCH_REFS = frozenset({"main", "master", "develop", "dev", "devel", "trunk", "head", "latest", "stable", "nightly"})
58
+ _URL_VERSION_RE = re.compile(r"(?:^|[/_-])v?(\d+(?:[._]\d+){1,3})(?=[^/]*$|/)")
59
+ _URL_BRANCH_RE = re.compile(r"/(?:archive|zip|tarball|zipball)/(?:refs/heads/)?(?:main|master|develop|trunk|HEAD)\b"
60
+ r"|/refs/heads/|/(?:main|master)\.(?:zip|tar\.gz|tgz)$", re.IGNORECASE)
61
+ _VERSION_TOKEN_RE = re.compile(r"^\d+(?:\.\d+){0,3}$")
62
+ # CMake >= 3.19 version range: `1.2...2.0` (inclusive) / `1.2...<2.0`.
63
+ _VERSION_RANGE_RE = re.compile(r"^(\d+(?:\.\d+){0,3})\.\.\.(<?)(\d+(?:\.\d+){0,3})$")
64
+ _CPM_SHORTHAND_RE = re.compile(r"^(?:(gh|gl|bb):)?([^@#\s]+?)(?:@([^#\s]+))?(?:#(\S+))?$")
65
+ # CMake modules and build tools found with find_package that aren't
66
+ # third-party code shipped with the project.
67
+ _FIND_PACKAGE_TOOLING = frozenset(
68
+ {"threads", "pkgconfig", "git", "doxygen", "python", "python2", "python3", "pythoninterp", "pythonlibs",
69
+ "perl", "sphinx", "bison", "flex", "swig", "java", "jni", "cuda", "openmp", "zephyr", "librt", "winsock",
70
+ "cmakeparsearguments", "packagehandlestandardargs"}
71
+ )
72
+ _TEST_DEPS = frozenset({"gtest", "googletest", "gmock", "catch2", "catch", "doctest", "benchmark", "boost.test", "cppunit",
73
+ "check", "cmocka", "unity"})
74
+ _TEST_SEGMENTS = frozenset({"test", "tests", "testing", "unittest", "unittests", "unit_test", "fuzz", "fuzztest",
75
+ "bench", "benchmark", "benchmarks", "example", "examples", "sample", "samples", "demo"})
76
+
77
+ # Conan
78
+ _CONAN_REF_RE = re.compile(r"\b([A-Za-z0-9_][A-Za-z0-9_.+-]{0,100})/(\[[^\]\n]{1,80}\]|[A-Za-z0-9_.+~-]{1,64})"
79
+ r"(?:@[\w.+-]{1,64}/[\w.+-]{1,64})?(?:#[0-9a-f]{1,64})?")
80
+ _CONAN_SECTION_RE = re.compile(r"^[ \t]*\[([a-z_]+)\][ \t]*$", re.MULTILINE)
81
+ _CONAN_REQUIRE_NAMES = frozenset({"requires", "tool_requires", "build_requires", "test_requires"})
82
+ _DEV_SECTIONS = frozenset({"tool_requires", "build_requires", "test_requires"})
83
+
84
+
85
+ MANIFEST_NAMES = frozenset(_MANIFEST_NAMES)
86
+ MANIFEST_SUFFIXES = frozenset({".cmake"})
87
+
88
+
89
+ def is_manifest(path: Path) -> bool:
90
+ """A file ``manifest_records`` understands (for the analyzer's own walk)."""
91
+ return path.name in MANIFEST_NAMES or path.suffix == ".cmake"
92
+
93
+
94
+ def manifest_records(path: Path, text: str, relative: str) -> tuple[list, list]:
95
+ """``(DependencyHint list, SupplyChainIssue list)`` from one manifest.
96
+ A malformed manifest yields nothing; it never raises."""
97
+ if DependencyHint is None or "cmake" not in DEPENDENCY_ECOSYSTEMS or len(text) > _MAX_FILE_CHARS:
98
+ return [], []
99
+ name = path.name
100
+ try:
101
+ if name == "vcpkg.json":
102
+ return parse_vcpkg_json(text, relative), []
103
+ if name == "conanfile.txt":
104
+ return parse_conanfile_txt(text, relative), []
105
+ if name == "conanfile.py":
106
+ return parse_conanfile_py(text, relative), []
107
+ if name == "conan.lock":
108
+ return parse_conan_lock(text, relative), []
109
+ if name == "CMakeLists.txt" or path.suffix == ".cmake":
110
+ return parse_cmake(text, relative)
111
+ except (ValueError, TypeError, KeyError, AttributeError, RecursionError, MemoryError):
112
+ pass # malformed or hostile (deeply nested JSON, unparsable Python)
113
+ return [], []
114
+
115
+
116
+ def native_dependencies(root: Path, skip_dirs: set[str]) -> tuple[list, list]:
117
+ """Walk ``root`` and collect every manifest's records (deduped)."""
118
+ deps: list = []
119
+ issues: list = []
120
+ for path in iter_repo_files(root, suffixes=set(MANIFEST_SUFFIXES), names=set(MANIFEST_NAMES), skip_dirs=skip_dirs):
121
+ text = read_source(path)
122
+ if text is None:
123
+ continue
124
+ found, flagged = manifest_records(path, text, rel(path, root))
125
+ deps.extend(found)
126
+ issues.extend(flagged)
127
+ return dedupe_dependencies(deps), issues
128
+
129
+
130
+ def dedupe_dependencies(deps: list) -> list:
131
+ seen: set[tuple] = set()
132
+ out = []
133
+ for dep in deps:
134
+ key = (dep.ecosystem, dep.name, dep.version, dep.file, dep.dev)
135
+ if key not in seen:
136
+ seen.add(key)
137
+ out.append(dep)
138
+ return out
139
+
140
+
141
+ def _is_test_manifest(relative: str) -> bool:
142
+ return any(p.lower() in _TEST_SEGMENTS for p in PurePosixPath(relative).parts[:-1])
143
+
144
+
145
+ _NEWLINE_RE = re.compile(r"\n")
146
+ # A JSON string literal (unterminated: up to the end of the line).
147
+ _JSON_STRING_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"?')
148
+
149
+
150
+ class _Lines:
151
+ """Line numbers for one text from a line-start table built once, so
152
+ each lookup is a bisect instead of a scan from the top."""
153
+
154
+ def __init__(self, text: str) -> None:
155
+ self.starts = [0, *(m.end() for m in _NEWLINE_RE.finditer(text))]
156
+
157
+ def line(self, offset: int) -> int:
158
+ return bisect.bisect_right(self.starts, max(0, offset))
159
+
160
+ def offset(self, lineno: int, col: int = 0) -> int:
161
+ return self.starts[min(max(lineno, 1), len(self.starts)) - 1] + max(0, col)
162
+
163
+
164
+ def _string_offsets(text: str) -> dict[str, int]:
165
+ """Offset of the first JSON string literal with each raw content, in one
166
+ pass, so a lookup per dependency doesn't search the whole text."""
167
+ out: dict[str, int] = {}
168
+ for match in _JSON_STRING_RE.finditer(text):
169
+ out.setdefault(match.group(1), match.start())
170
+ return out
171
+
172
+
173
+ # ---------- vcpkg ----------
174
+
175
+
176
+ def _list(value: object) -> list:
177
+ """A JSON list field, or nothing (`"dependencies": "zlib"` is malformed)."""
178
+ return value if isinstance(value, list) else []
179
+
180
+
181
+ def parse_vcpkg_json(text: str, relative: str) -> list:
182
+ data = json.loads(text)
183
+ if not isinstance(data, dict):
184
+ return []
185
+ baseline = data.get("builtin-baseline")
186
+ overrides = {}
187
+ for item in _list(data.get("overrides")):
188
+ if isinstance(item, dict) and isinstance(item.get("name"), str):
189
+ version = item.get("version") or item.get("version-string") or item.get("version-semver") or item.get("version-date")
190
+ if isinstance(version, str):
191
+ overrides[item["name"]] = version
192
+ dev_manifest = _is_test_manifest(relative)
193
+ lines = _Lines(text)
194
+ strings = _string_offsets(text)
195
+ out = []
196
+ for item in _list(data.get("dependencies")):
197
+ if isinstance(item, str):
198
+ name, minimum, host = item, None, False
199
+ elif isinstance(item, dict) and isinstance(item.get("name"), str):
200
+ name, minimum, host = item["name"], item.get("version>="), bool(item.get("host"))
201
+ else:
202
+ continue
203
+ if name in overrides:
204
+ version, resolved, how = overrides[name], True, "override"
205
+ elif isinstance(minimum, str):
206
+ version, resolved, how = f">={minimum}", False, "version>="
207
+ else:
208
+ version, resolved, how = "", False, "baseline"
209
+ evidence = f"vcpkg {name} ({how}" + (f", builtin-baseline {baseline}" if isinstance(baseline, str) else "") + ")"
210
+ out.append(DependencyHint(
211
+ name=name, version=version, ecosystem="vcpkg", file=relative, line=lines.line(strings[name]) if name in strings else None,
212
+ dev=host or dev_manifest, resolved=resolved, evidence_text=evidence,
213
+ ))
214
+ return out
215
+
216
+
217
+ # ---------- Conan ----------
218
+
219
+
220
+ def _conan_hint(ref: re.Match[str], lines: _Lines, relative: str, dev: bool, offset: int, resolved: bool = False):
221
+ version = ref.group(2)
222
+ return DependencyHint(
223
+ name=ref.group(1), version=version, ecosystem="conan", file=relative, line=lines.line(offset),
224
+ dev=dev, resolved=resolved and not version.startswith("["), evidence_text=ref.group(0),
225
+ )
226
+
227
+
228
+ def parse_conanfile_txt(text: str, relative: str) -> list:
229
+ out = []
230
+ dev_manifest = _is_test_manifest(relative)
231
+ lines = _Lines(text)
232
+ sections = list(_CONAN_SECTION_RE.finditer(text))
233
+ for i, section in enumerate(sections):
234
+ kind = section.group(1)
235
+ if kind not in ("requires", *_DEV_SECTIONS):
236
+ continue
237
+ end = sections[i + 1].start() if i + 1 < len(sections) else len(text)
238
+ offset = section.end()
239
+ for raw_line in text[section.end() : end].splitlines(keepends=True):
240
+ line = raw_line.split("#", 1)[0]
241
+ ref = _CONAN_REF_RE.search(line)
242
+ if ref is not None:
243
+ out.append(_conan_hint(ref, lines, relative, kind in _DEV_SECTIONS or dev_manifest, offset + ref.start()))
244
+ offset += len(raw_line)
245
+ return out
246
+
247
+
248
+ def parse_conanfile_py(text: str, relative: str) -> list:
249
+ """Requirements from a ``conanfile.py``, read with ``ast`` and never run:
250
+ ``requires = "a/1.0"`` / a tuple or list (class or module level), and
251
+ ``self.requires("x/1.0")`` and the tool/build/test forms. Comments and
252
+ docstrings are not code, so they don't count."""
253
+ try:
254
+ tree = ast.parse(text)
255
+ except (SyntaxError, ValueError, RecursionError, MemoryError):
256
+ return []
257
+ dev_manifest = _is_test_manifest(relative)
258
+ lines = _Lines(text)
259
+ out = []
260
+
261
+ def literal_refs(node: ast.AST) -> list[ast.Constant]:
262
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
263
+ return [node]
264
+ if isinstance(node, (ast.Tuple, ast.List)):
265
+ return [e for e in node.elts if isinstance(e, ast.Constant) and isinstance(e.value, str)]
266
+ return []
267
+
268
+ def emit(kind: str, constants: list[ast.Constant]) -> None:
269
+ for constant in constants:
270
+ ref = _CONAN_REF_RE.fullmatch(constant.value)
271
+ if ref is not None:
272
+ offset = lines.offset(constant.lineno, constant.col_offset)
273
+ out.append(_conan_hint(ref, lines, relative, kind in _DEV_SECTIONS or dev_manifest, offset))
274
+
275
+ for node in ast.walk(tree):
276
+ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
277
+ if node.targets[0].id in _CONAN_REQUIRE_NAMES:
278
+ emit(node.targets[0].id, literal_refs(node.value))
279
+ elif (
280
+ isinstance(node, ast.Call)
281
+ and isinstance(node.func, ast.Attribute)
282
+ and isinstance(node.func.value, ast.Name)
283
+ and node.func.value.id == "self"
284
+ and node.func.attr in _CONAN_REQUIRE_NAMES
285
+ and node.args
286
+ ):
287
+ emit(node.func.attr, literal_refs(node.args[0]))
288
+ out.sort(key=lambda d: d.line or 0)
289
+ return out
290
+
291
+
292
+ def parse_conan_lock(text: str, relative: str) -> list:
293
+ """Conan 2 (``requires`` / ``build_requires`` lists) and Conan 1.x
294
+ (``graph_lock.nodes``, build-context nodes are ``dev``) lockfiles."""
295
+ data = json.loads(text)
296
+ if not isinstance(data, dict):
297
+ return []
298
+ entries: list[tuple[str, bool]] = []
299
+ for key, dev in (("requires", False), ("build_requires", True), ("python_requires", True)):
300
+ entries.extend((entry, dev) for entry in _list(data.get(key)) if isinstance(entry, str))
301
+ graph = data.get("graph_lock")
302
+ nodes = graph.get("nodes") if isinstance(graph, dict) else None
303
+ if isinstance(nodes, dict):
304
+ for node in nodes.values():
305
+ if isinstance(node, dict) and isinstance(node.get("ref"), str):
306
+ entries.append((node["ref"], node.get("context") == "build"))
307
+ lines = _Lines(text)
308
+ strings = _string_offsets(text)
309
+ out = []
310
+ for entry, dev in entries:
311
+ ref = _CONAN_REF_RE.match(entry)
312
+ if ref is not None:
313
+ out.append(_conan_hint(ref, lines, relative, dev, strings.get(entry, 0), resolved=True))
314
+ return out
315
+
316
+
317
+ # ---------- CMake ----------
318
+
319
+
320
+ _CMAKE_SPECIAL_RE = re.compile(r"#\[(=*)\[|#|\"|\[(=*)\[")
321
+
322
+
323
+ def _cmake_masks(text: str) -> tuple[str, str]:
324
+ """``(clean, bare)`` in one linear pass. ``clean``: comments (``#`` and
325
+ ``#[[ ]]``) and bracket arguments (``[[ ]]``, whose content is never a
326
+ command or a dependency) blanked, quoted strings kept for argument
327
+ values. ``bare``: quoted string contents blanked too, so a command inside
328
+ ``message("find_package(x)")`` doesn't count. Unterminated constructs
329
+ run to the end of the file."""
330
+ clean: list[str] = []
331
+ bare: list[str] = []
332
+ pos = 0
333
+ n = len(text)
334
+ while pos < n:
335
+ match = _CMAKE_SPECIAL_RE.search(text, pos)
336
+ if match is None:
337
+ break
338
+ start = match.start()
339
+ clean.append(text[pos:start])
340
+ bare.append(text[pos:start])
341
+ token = match.group(0)
342
+ if token.startswith("#[") or (token.startswith("[") and match.group(2) is not None):
343
+ equals = match.group(1) if token.startswith("#") else match.group(2)
344
+ close = text.find("]" + equals + "]", match.end())
345
+ end = n if close == -1 else close + len(equals) + 2
346
+ blank = _blank(text[start:end])
347
+ clean.append(blank)
348
+ bare.append(blank)
349
+ elif token == "#":
350
+ newline = text.find("\n", start)
351
+ end = n if newline == -1 else newline
352
+ blank = _blank(text[start:end])
353
+ clean.append(blank)
354
+ bare.append(blank)
355
+ else: # quoted argument
356
+ body = _CMAKE_QUOTED_BODY_RE.match(text, match.end())
357
+ end = body.end() if body else match.end()
358
+ if end < n:
359
+ end += 1 # closing quote
360
+ clean.append(text[start:end])
361
+ closed = end - start >= 2 and text[end - 1] == '"'
362
+ inner_end = end - 1 if closed else end
363
+ bare.append('"' + _blank(text[start + 1 : inner_end]) + ('"' if closed else ""))
364
+ pos = end
365
+ clean.append(text[pos:])
366
+ bare.append(text[pos:])
367
+ return "".join(clean), "".join(bare)
368
+
369
+
370
+ def _blank(text: str) -> str:
371
+ return re.sub(r"[^\n]", " ", text)
372
+
373
+
374
+ def _paren_pairs(bare: str) -> dict[int, int]:
375
+ """Matching ``)`` for every ``(`` in one pass (strings already blanked)."""
376
+ pairs: dict[int, int] = {}
377
+ stack: list[int] = []
378
+ for match in _PAREN_RE.finditer(bare):
379
+ if match.group(0) == "(":
380
+ stack.append(match.start())
381
+ elif stack:
382
+ pairs[stack.pop()] = match.start()
383
+ return pairs
384
+
385
+
386
+ def _cmake_args(clean: str, open_paren: int, pairs: dict[int, int]) -> list[str] | None:
387
+ close = pairs.get(open_paren)
388
+ if close is None or close - open_paren > _MAX_ARGS_SPAN:
389
+ return None
390
+ body = clean[open_paren + 1 : close]
391
+ return [m.group(1) if m.group(1) is not None else m.group(2) for m in _CMAKE_TOKEN_RE.finditer(body)]
392
+
393
+
394
+ def _keyword_values(args: list[str]) -> dict[str, str]:
395
+ values: dict[str, str] = {}
396
+ for i, token in enumerate(args[:-1]):
397
+ if token.isupper() and token not in values:
398
+ values[token] = args[i + 1]
399
+ return values
400
+
401
+
402
+ def _ref_issue(name: str, ref: str | None, url: str, relative: str, line: int | None):
403
+ if ref is not None and "${" in ref:
404
+ return None
405
+ if ref is None or ref.lower() in _BRANCH_REFS:
406
+ severity = "medium"
407
+ what = f"branch `{ref}`" if ref else "no GIT_TAG (the default branch)"
408
+ elif _SHA40_RE.match(ref):
409
+ return None
410
+ else:
411
+ severity = "low"
412
+ what = f"`{ref}`, a tag or short SHA"
413
+ return SupplyChainIssue(
414
+ kind="mutable_vcs_dependency", file=relative, line=line, package=name, ecosystem="cmake", severity=severity,
415
+ evidence_text=f"{name}: git dependency on {what}, not a commit SHA; the remote can change what is built: {url}",
416
+ )
417
+
418
+
419
+ def _url_issue(name: str, url: str, values: dict[str, str], relative: str, line: int | None):
420
+ if "${" in url or "URL_HASH" in values or "URL_MD5" in values:
421
+ return None
422
+ branch = _URL_BRANCH_RE.search(url) is not None
423
+ return SupplyChainIssue(
424
+ kind="mutable_vcs_dependency", file=relative, line=line, package=name, ecosystem="cmake", severity="medium",
425
+ evidence_text=f"{name}: archive URL{' of a branch' if branch else ''} downloaded without URL_HASH: {url}",
426
+ )
427
+
428
+
429
+ def _url_version(url: str) -> str:
430
+ if "${" in url:
431
+ return ""
432
+ match = _URL_VERSION_RE.search(url)
433
+ return match.group(1).replace("_", ".") if match else ""
434
+
435
+
436
+ def parse_cmake(text: str, relative: str) -> tuple[list, list]:
437
+ clean, bare = _cmake_masks(text)
438
+ pairs = _paren_pairs(bare)
439
+ deps: list = []
440
+ issues: list = []
441
+ dev_manifest = _is_test_manifest(relative)
442
+ lines = _Lines(text)
443
+ for match in _CMAKE_COMMAND_RE.finditer(bare):
444
+ command = match.group(1).lower()
445
+ args = _cmake_args(clean, match.end() - 1, pairs)
446
+ if not args:
447
+ continue
448
+ line = lines.line(match.start())
449
+ if command == "find_package":
450
+ dep = _find_package(args, relative, line, dev_manifest)
451
+ if dep is not None:
452
+ deps.append(dep)
453
+ continue
454
+ if command in ("cpmaddpackage", "cpmfindpackage") and len(args) == 1:
455
+ dep, issue = _cpm_shorthand(args[0], relative, line, dev_manifest)
456
+ else:
457
+ dep, issue = _download(command, args, relative, line, dev_manifest)
458
+ if dep is not None:
459
+ deps.append(dep)
460
+ if issue is not None:
461
+ issues.append(issue)
462
+ return deps, issues
463
+
464
+
465
+ def _find_package(args: list[str], relative: str, line: int, dev_manifest: bool):
466
+ name = args[0]
467
+ if "${" in name or name.lower() in _FIND_PACKAGE_TOOLING:
468
+ return None
469
+ version = ""
470
+ if len(args) > 1 and _VERSION_TOKEN_RE.match(args[1]):
471
+ version = ("==" if "EXACT" in args[2:] else ">=") + args[1]
472
+ elif len(args) > 1 and (version_range := _VERSION_RANGE_RE.match(args[1])):
473
+ low, exclusive, high = version_range.groups()
474
+ version = f">={low},{'<' if exclusive else '<='}{high}"
475
+ return DependencyHint(
476
+ name=name, version=version, ecosystem="cmake", file=relative, line=line,
477
+ dev=dev_manifest or name.lower() in _TEST_DEPS, evidence_text=f"find_package({' '.join(args[:4])})",
478
+ )
479
+
480
+
481
+ def _download(command: str, args: list[str], relative: str, line: int, dev_manifest: bool):
482
+ cpm = command in ("cpmaddpackage", "cpmfindpackage")
483
+ values = _keyword_values(args) if cpm else _keyword_values(args[1:])
484
+ name = values.get("NAME") if cpm else args[0]
485
+ if not name or "${" in name:
486
+ return None, None
487
+ repo = values.get("GIT_REPOSITORY") or (
488
+ f"https://github.com/{values['GITHUB_REPOSITORY']}" if "GITHUB_REPOSITORY" in values else None
489
+ )
490
+ url = values.get("URL")
491
+ ref = values.get("GIT_TAG")
492
+ if repo is None and url is None:
493
+ return None, None # SOURCE_DIR / SVN / local: nothing to pin
494
+ if cpm and ref is None and "VERSION" in values:
495
+ ref = "v" + values["VERSION"] # CPM's default tag for a VERSION
496
+ dev = dev_manifest or name.lower() in _TEST_DEPS
497
+ if repo is not None:
498
+ version = values.get("VERSION") if cpm and "VERSION" in values else ref
499
+ version = version if version and "${" not in version else ""
500
+ dep = DependencyHint(name=name, version=version, ecosystem="cmake", file=relative, line=line, dev=dev,
501
+ evidence_text=repo)
502
+ return dep, _ref_issue(name, ref, repo, relative, line)
503
+ assert url is not None
504
+ dep = DependencyHint(name=name, version=_url_version(url), ecosystem="cmake", file=relative, line=line, dev=dev,
505
+ evidence_text=url)
506
+ return dep, _url_issue(name, url, values, relative, line)
507
+
508
+
509
+ def _cpm_shorthand(spec: str, relative: str, line: int, dev_manifest: bool):
510
+ match = _CPM_SHORTHAND_RE.match(spec)
511
+ if match is None or "${" in spec:
512
+ return None, None
513
+ host, path, version, tag = match.groups()
514
+ base = {"gh": "https://github.com/", "gl": "https://gitlab.com/", "bb": "https://bitbucket.org/"}.get(host or "", "")
515
+ repo = base + path if base else path
516
+ name = path.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
517
+ ref = tag or (f"v{version}" if version else None)
518
+ dep = DependencyHint(name=name, version=version or tag or "", ecosystem="cmake", file=relative, line=line,
519
+ dev=dev_manifest or name.lower() in _TEST_DEPS, evidence_text=repo)
520
+ return dep, _ref_issue(name, ref, repo, relative, line)
521
+
522
+
523
+ __all__ = ["dedupe_dependencies", "is_manifest", "manifest_records", "native_dependencies", "parse_cmake", "parse_conan_lock", "parse_conanfile_py", "parse_conanfile_txt",
524
+ "parse_vcpkg_json"]