attackmap-analyzer-c 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.
- attackmap_analyzer_c/__init__.py +3 -0
- attackmap_analyzer_c/analyzer.py +431 -0
- attackmap_analyzer_c/native_deps.py +524 -0
- attackmap_analyzer_c/native_sinks.py +1737 -0
- attackmap_analyzer_c-0.2.0.dist-info/METADATA +167 -0
- attackmap_analyzer_c-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_c-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_c-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_c-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_c-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|