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,3 @@
1
+ from .analyzer import CppAnalyzer
2
+
3
+ __all__ = ["CppAnalyzer"]
@@ -0,0 +1,601 @@
1
+ """C++ ecosystem analyzer for AttackMap.
2
+
3
+ Coverage (v0.1):
4
+ - Web frameworks: Crow (CROW_ROUTE macro routes), Pistache (Routes::Get/Post/...),
5
+ Drogon (registerHandler + ADD_METHOD_TO macro), cpprestsdk (http_listener URL +
6
+ listener.support method handlers), cpp-httplib (svr.Get/Post/...), oat++
7
+ (ENDPOINT + AUTHORIZATION), restbed (set_path + set_method_handler); see
8
+ ``framework_routes``
9
+ - HTTP clients (external calls): libcurl (CURLOPT_URL), cpprestsdk
10
+ http_client(URL), cpr (cpr::Get/Post/Put/Delete with cpr::Url{...})
11
+ - Databases: libpqxx (pqxx::connection), MySQL X DevAPI (mysqlx::Session),
12
+ mongocxx (mongocxx::client + mongocxx::uri), redis-plus-plus
13
+ (sw::redis::Redis), SOCI (soci::session), sqlite_orm (header-only sqlite)
14
+ - Auth/crypto: OpenSSL (TLS / EVP / RAND), Botan (TLS / KDF / cipher),
15
+ libsodium (crypto_pwhash, crypto_secretbox, AEAD), Crypto++
16
+ (CryptoPP::AES/SHA256/Argon2), JWT C++ libraries (jwt::create, jwt::decode,
17
+ jwt::verify)
18
+ - Secrets: getenv / std::getenv with secret-shaped names
19
+ - Service hints: project name from CMakeLists.txt project() declaration
20
+ - Dependencies (AttackMap#191): CMake FetchContent / ExternalProject / CPM /
21
+ find_package, Conan and vcpkg manifests (see ``native_deps``)
22
+ - Native sinks (AttackMap#189): unbounded copies, non-constant formats,
23
+ command execution, input-controlled copy lengths and allocation-size
24
+ overflows, emitted as ``CodeWeakness`` records (see ``native_sinks``,
25
+ shared verbatim with attackmap-analyzer-c)
26
+
27
+ C++ shares much of its data-plane vocabulary with C (libcurl, OpenSSL,
28
+ libsodium) — those patterns are duplicated here so a pure-C++ project
29
+ without the C analyzer installed still gets full coverage. AttackMap's
30
+ overlay deduplication handles cases where both analyzers fire on the
31
+ same evidence.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from pathlib import Path
38
+
39
+ from attackmap.sdk import (
40
+ DEFAULT_SKIP_DIRS,
41
+ AnalyzerMetadata,
42
+ AuthHint,
43
+ DatabaseHint,
44
+ EntrypointHint,
45
+ ExternalCall,
46
+ FrameworkHint,
47
+ iter_repo_files,
48
+ line_of,
49
+ read_source,
50
+ rel,
51
+ Route,
52
+ ScanResult,
53
+ SecretHint,
54
+ ServiceHint,
55
+ )
56
+
57
+ from .framework_routes import framework_routes
58
+ from .native_deps import MANIFEST_NAMES, MANIFEST_SUFFIXES, dedupe_dependencies, is_manifest, manifest_records
59
+ from .native_sinks import find_native_weaknesses
60
+
61
+ CODE_SUFFIXES = {".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".ipp", ".tpp"}
62
+ # C++-specific additions to the shared skip list (which already covers build/,
63
+ # out/, vendor/, node_modules/, .git/, ...). Matched against directory names
64
+ # *inside* the repo only.
65
+ SKIP_DIRS = DEFAULT_SKIP_DIRS | {"_deps", "third_party", "external", ".cache", "Debug", "Release"}
66
+
67
+ # ---------- .h ownership (shared rule with attackmap-analyzer-c) ----------
68
+ #
69
+ # A ``.h`` header is either C or C++, and only one analyzer may claim it, or a
70
+ # C++ project's headers (e.g. Drogon controllers, whose routes live in the
71
+ # header) get analyzed as C and labelled language ``c``. The rule is decided
72
+ # per repo and is mirrored *verbatim* in attackmap-analyzer-c, so both
73
+ # plugins agree whichever of ``-m c`` / ``-m cpp`` is selected:
74
+ #
75
+ # ``.h`` belongs to C++ if the repo has any C++ source/header (the suffixes
76
+ # below) or a CMakeLists.txt that enables CXX (``project(... CXX ...)``,
77
+ # ``enable_language(CXX)`` or ``CMAKE_CXX_STANDARD``); otherwise to C.
78
+ #
79
+ # Keep ``_CXX_MARKER_SUFFIXES`` and ``_CMAKE_CXX_PATTERN`` in sync with the C
80
+ # plugin (which also ignores markers under this plugin's extra
81
+ # ``Debug``/``Release`` skip dirs).
82
+ _CXX_MARKER_SUFFIXES = CODE_SUFFIXES
83
+ _CMAKE_CXX_PATTERN = re.compile(
84
+ r"\b(?i:project)\s*\([^)]*\bCXX\b"
85
+ r"|\b(?i:enable_language)\s*\(\s*CXX\b"
86
+ r"|\bCMAKE_CXX_STANDARD\b",
87
+ )
88
+ _HEADER_SUFFIX = ".h"
89
+ _SNIPPET_MAX_CHARS = 160
90
+
91
+
92
+ # ---------- Patterns ----------
93
+
94
+ # Crow: CROW_ROUTE(app, "/path") or CROW_ROUTE(app, "/path").methods("POST"_method)
95
+ CROW_ROUTE_PATTERN = re.compile(
96
+ r'\bCROW_ROUTE\s*\(\s*\w+\s*,\s*"([^"]+)"\s*\)(?P<chain>(?:\s*\.\s*\w+\([^)]*\))*)',
97
+ )
98
+ # Crow .methods("POST"_method, "PUT"_method)
99
+ CROW_METHODS_PATTERN = re.compile(
100
+ r'\.methods\s*\(\s*((?:"[A-Z]+"_method(?:\s*,\s*)?)+)\s*\)',
101
+ )
102
+
103
+ # Pistache: Routes::Get(router, "/path", handler), Routes::Post(...)
104
+ PISTACHE_ROUTE_PATTERN = re.compile(
105
+ r'\b(?:Pistache::)?(?:Rest::)?Routes::(Get|Post|Put|Delete|Patch|Head|Options)\s*\(\s*\w+\s*,\s*"([^"]+)"',
106
+ )
107
+
108
+ # Drogon: app().registerHandler("/path", handler, {Drogon::Get, Drogon::Post})
109
+ # Handler arg may be a lambda containing commas, so match non-greedy across the
110
+ # middle and anchor on the trailing `{ Drogon::* }` brace.
111
+ DROGON_REGISTER_PATTERN = re.compile(
112
+ r'\bregisterHandler\s*\(\s*"([^"]+)"\s*,.+?,\s*\{([^}]*)\}\s*\)',
113
+ re.DOTALL,
114
+ )
115
+
116
+ # Drogon HttpController: METHOD_LIST_BEGIN ... METHOD_LIST_END with
117
+ # ADD_METHOD_TO(Ctrl::handler, "/abs/path", Get, Post, "Filter") - absolute path
118
+ # METHOD_ADD(Ctrl::handler, "/rel/{id}", Get) - relative to
119
+ # the controller's class path, /<namespace parts>/<ClassName>
120
+ # and HttpSimpleController: PATH_ADD("/abs/path", Get, "Filter").
121
+ # The trailing group is the rest of the argument list (methods and filters).
122
+ DROGON_ADD_METHOD_PATTERN = re.compile(
123
+ r'\bADD_METHOD_TO\s*\(\s*[^,()]+,\s*"([^"]*)"\s*((?:,[^()]*)?)\)',
124
+ )
125
+ DROGON_METHOD_ADD_PATTERN = re.compile(
126
+ r'\bMETHOD_ADD\s*\(\s*(?:(?:::)?(\w+(?:::\w+)*)::)?\w+\s*,\s*"([^"]*)"\s*((?:,[^()]*)?)\)',
127
+ )
128
+ DROGON_PATH_ADD_PATTERN = re.compile(
129
+ r'\bPATH_ADD\s*\(\s*"([^"]*)"\s*((?:,[^()]*)?)\)',
130
+ )
131
+ _DROGON_METHOD_RE = re.compile(r'\b(?:drogon::)?(Get|Post|Put|Delete|Patch|Head|Options)\b')
132
+ # class Foo : public drogon::HttpController<Foo> (also final / HttpSimpleController)
133
+ DROGON_CONTROLLER_PATTERN = re.compile(
134
+ r'\b(?:class|struct)\s+(\w+)\s*(?:final\s*)?:\s*public\s+(?:::)?(?:drogon::)?Http(?:Simple)?Controller\s*<',
135
+ )
136
+ _NAMESPACE_OR_BRACE_RE = re.compile(r'\bnamespace\s+((?:\w+::)*\w+)\s*\{|[{}]')
137
+
138
+ # cpprestsdk: web::http::experimental::listener::http_listener listener("https://example.com/api");
139
+ CPPRESTSDK_LISTENER_PATTERN = re.compile(
140
+ r'\bhttp_listener\s+\w+\s*\(\s*(?:U\s*\(\s*)?"(https?://[^")]+)"',
141
+ )
142
+
143
+ # External HTTP calls
144
+ OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
145
+ re.compile(r'\bcurl_easy_setopt\s*\(\s*\w+\s*,\s*CURLOPT_URL\s*,\s*"(https?://[^"]+)"'),
146
+ re.compile(r'\bcpr::(?:Get|Post|Put|Delete|Patch|Head)\s*\(\s*cpr::Url\s*\{\s*"(https?://[^"]+)"'),
147
+ # Permissive http_client(URL) — namespace prefix may be `using`-elided.
148
+ re.compile(r'\bhttp_client\s+\w+\s*\(\s*(?:U\s*\(\s*)?"(https?://[^")]+)"'),
149
+ re.compile(r'\bRequest\s+\w+\s*\(\s*"(https?://[^"]+)"'),
150
+ ]
151
+
152
+ # DBs
153
+ DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
154
+ (re.compile(r'\bpqxx::connection\b|\bpqxx::work\b|#\s*include\s+["<]pqxx/'), "postgresql"),
155
+ (re.compile(r'\bmysqlx::Session\b|\bmysqlx::SessionSettings\b|#\s*include\s+["<]mysqlx/'), "mysql"),
156
+ (re.compile(r'\bmongocxx::client\b|\bmongocxx::uri\b|#\s*include\s+["<]mongocxx/'), "mongodb"),
157
+ (re.compile(r'\bsw::redis::Redis\b|\bsw::redis::RedisCluster\b|#\s*include\s+["<]sw/redis\+\+'), "redis"),
158
+ (re.compile(r'\bsoci::session\b|\bsoci::statement\b'), "sql"),
159
+ (re.compile(r'\bsqlite_orm::make_storage\b|\bsqlite3_open(?:_v2)?\s*\('), "sqlite"),
160
+ (re.compile(r'\bsql::mysql::MySQL_Driver\b|\bMySQL_Connection\b'), "mysql"),
161
+ ]
162
+
163
+ # Auth / crypto
164
+ AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
165
+ (re.compile(r'\bcrypto_pwhash(?:_argon2(?:i|id))?\s*\(|\bcrypto_pwhash_str\s*\('), "argon2", 0.9),
166
+ (re.compile(r'\bargon2(?:i|d|id)?_hash\w*\s*\(|\bCryptoPP::Argon2\b'), "argon2", 0.9),
167
+ (re.compile(r'\bbcrypt(?:_hashpw|_checkpw|_gensalt)?\s*\(|\bCryptoPP::BCrypt\b'), "bcrypt", 0.9),
168
+ (re.compile(r'\bscrypt(?:_kdf)?\s*\(|\bBotan::Scrypt\b|\bCryptoPP::Scrypt\b'), "scrypt", 0.9),
169
+ (re.compile(r'\bcrypto_secretbox\w*\s*\(|\bcrypto_aead_(?:chacha20poly1305|aes256gcm)\w*\s*\('), "libsodium_aead", 0.85),
170
+ (re.compile(r'\bSSL_CTX_new\s*\(|\bSSL_new\s*\(|\bTLS_method\s*\(|\bTLS_(?:client|server)_method\s*\('), "openssl_tls", 0.85),
171
+ (re.compile(r'\bEVP_PKEY_new\s*\(|\bEVP_(?:Encrypt|Decrypt)Init\w*\s*\('), "openssl_evp", 0.8),
172
+ (re.compile(r'\bBotan::TLS::(?:Server|Client)\b|\bBotan::Cipher_Mode\b|\bBotan::PKCS5_PBKDF2\b'), "botan", 0.85),
173
+ (re.compile(r'\bjwt::create\s*\(|\bjwt::decode\s*\(|\bjwt::verify\s*\(|#\s*include\s+["<]jwt-cpp/'), "jwt", 0.85),
174
+ (re.compile(r'\bCryptoPP::AES\b|\bCryptoPP::SHA(?:256|384|512|3)\b|\bCryptoPP::HMAC\b'), "cryptopp", 0.85),
175
+ (re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
176
+ (re.compile(r'\bBearer\b'), "bearer_token", 0.6),
177
+ (re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
178
+ ]
179
+
180
+ FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
181
+ (re.compile(r'\bcrow::SimpleApp\b|#\s*include\s+["<]crow\.h|\bCROW_ROUTE\s*\('), "crow"),
182
+ (re.compile(r'\bPistache::(?:Http|Rest)\b|#\s*include\s+["<]pistache/'), "pistache"),
183
+ (re.compile(r'\bdrogon::HttpAppFramework\b|\bapp\(\)\.registerHandler\b|#\s*include\s+["<]drogon/'), "drogon"),
184
+ (re.compile(r'\bweb::http::experimental::listener\b|#\s*include\s+["<]cpprest/'), "cpprestsdk"),
185
+ (re.compile(r'\bboost::beast\b|#\s*include\s+["<]boost/beast'), "boost-beast"),
186
+ (re.compile(r'\bPoco::Net::HTTPServer\b|#\s*include\s+["<]Poco/Net/'), "poco-net"),
187
+ (re.compile(r'\boatpp::web::server\b|#\s*include\s+["<]oatpp/'), "oatpp"),
188
+ (re.compile(r'\bhttplib::(?:SSL)?Server\b|#\s*include\s+["<]httplib\.h'), "cpp-httplib"),
189
+ (re.compile(r'\brestbed::(?:Service|Resource)\b|#\s*include\s+<restbed>'), "restbed"),
190
+ (re.compile(r'#\s*include\s+["<]openssl/'), "openssl"),
191
+ (re.compile(r'#\s*include\s+["<]botan/'), "botan"),
192
+ (re.compile(r'#\s*include\s+["<]sodium\.h'), "libsodium"),
193
+ (re.compile(r'#\s*include\s+["<]cryptopp/'), "cryptopp"),
194
+ (re.compile(r'#\s*include\s+["<]curl/curl\.h|#\s*include\s+["<]cpr/'), "libcurl"),
195
+ (re.compile(r'#\s*include\s+["<]pqxx/'), "libpqxx"),
196
+ (re.compile(r'#\s*include\s+["<]mongocxx/'), "mongocxx"),
197
+ (re.compile(r'#\s*include\s+["<]sw/redis\+\+'), "redis-plus-plus"),
198
+ ]
199
+
200
+ ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
201
+ (re.compile(r'\bapp\.(?:port|bindaddr|run|multithreaded)\(.*?\)\.run\s*\(\s*\)', re.DOTALL), "crow_app_run"),
202
+ (re.compile(r'\bcrow::SimpleApp\b'), "crow_app"),
203
+ (re.compile(r'\b\w+\.serve\s*\(\s*\)', re.IGNORECASE), "pistache_serve"),
204
+ (re.compile(r'\bapp\(\)\.run\s*\(\s*\)|\bdrogon::app\(\)\.run\b'), "drogon_run"),
205
+ (re.compile(r'\blistener\.open\s*\(\s*\)\.wait\s*\(\s*\)|\blistener\.open\s*\(\s*\)\.then\b'), "cpprestsdk_listener_open"),
206
+ (re.compile(r'\bPoco::Net::HTTPServer\s+\w+'), "poco_http_server"),
207
+ ]
208
+
209
+ # Secrets via getenv / std::getenv
210
+ SECRET_PATTERNS: list[re.Pattern[str]] = [
211
+ re.compile(
212
+ r'\b(?:std::)?(?:secure_)?getenv\s*\(\s*"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
213
+ ),
214
+ ]
215
+
216
+
217
+ def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
218
+ # Kept local rather than ``attackmap.sdk.line_snippet(content, line_of(...))``:
219
+ # the SDK helper indexes ``str.splitlines()``, which also breaks on form
220
+ # feeds and lone ``\r``, so its line numbering can disagree with
221
+ # ``line_of`` (which counts ``\n`` only).
222
+ line_start = content.rfind("\n", 0, offset) + 1
223
+ line_end = content.find("\n", offset)
224
+ if line_end == -1:
225
+ line_end = len(content)
226
+ line = content[line_start:line_end].strip()
227
+ if len(line) > max_chars:
228
+ line = line[: max_chars - 1] + "…"
229
+ return line
230
+
231
+
232
+ def _project_name_from_cmake(text: str) -> str | None:
233
+ match = re.search(r"\bproject\s*\(\s*([A-Za-z0-9_\-]+)", text)
234
+ if match:
235
+ return match.group(1)
236
+ return None
237
+
238
+
239
+ def _drogon_methods(args: str) -> list[str]:
240
+ """Sorted HTTP methods named in a Drogon macro's trailing arguments
241
+ (filter-name strings are ignored); ``["ANY"]`` when none constrain it."""
242
+ unquoted = re.sub(r'"[^"]*"', "", args)
243
+ methods = sorted({m.upper() for m in _DROGON_METHOD_RE.findall(unquoted)})
244
+ return methods or ["ANY"]
245
+
246
+
247
+ def _namespaces_at(content: str, offset: int) -> list[str]:
248
+ """Namespace names enclosing ``offset`` (outermost first), from
249
+ ``namespace a { namespace b {`` or C++17 ``namespace a::b {``."""
250
+ stack: list[str | None] = []
251
+ for match in _NAMESPACE_OR_BRACE_RE.finditer(content, 0, offset):
252
+ if match.group(1):
253
+ stack.append(match.group(1))
254
+ elif match.group(0) == "{":
255
+ stack.append(None)
256
+ elif stack:
257
+ stack.pop()
258
+ parts: list[str] = []
259
+ for name in stack:
260
+ if name:
261
+ parts.extend(name.split("::"))
262
+ return parts
263
+
264
+
265
+ def _drogon_controller_paths(content: str) -> dict[str, str]:
266
+ """Map each HttpController class name to its Drogon class path,
267
+ ``/<namespace parts>/<ClassName>`` (e.g. ``api::v1::User`` -> ``/api/v1/User``)."""
268
+ paths: dict[str, str] = {}
269
+ for match in DROGON_CONTROLLER_PATTERN.finditer(content):
270
+ name = match.group(1)
271
+ paths[name] = "/" + "/".join([*_namespaces_at(content, match.start()), name])
272
+ return paths
273
+
274
+
275
+ def _join_drogon_path(prefix: str, path: str) -> str:
276
+ if not path:
277
+ return prefix
278
+ return prefix.rstrip("/") + (path if path.startswith("/") else "/" + path)
279
+
280
+
281
+ class CppAnalyzer:
282
+ metadata = AnalyzerMetadata(
283
+ name="cpp",
284
+ display_name="C++ Analyzer",
285
+ version="0.2.0",
286
+ description="C++ analyzer covering Crow, Pistache, Drogon, cpprestsdk, cpp-httplib, oat++, restbed, libcurl/cpr, OpenSSL/Botan/libsodium/Crypto++, libpqxx/mongocxx/redis-plus-plus, plus memory-safety and command-exec sinks.",
287
+ scope="C++ source trees and CMake projects. Detects modern HTTP frameworks, common DB / crypto libraries, and function-local memory-safety / format-string / command-exec sinks tied to request and input sources.",
288
+ targets=["cpp", "c++", "crow", "pistache", "drogon", "cpprestsdk", "cpp-httplib", "oatpp", "restbed"],
289
+ languages=["cpp"],
290
+ priority=20,
291
+ experimental=True, # Like the C analyzer; regex coverage of C++ is more leaky than of stricter ecosystems.
292
+ enabled_by_default=False, # opt-in via `-m cpp` while experimental (AttackMap#221)
293
+ )
294
+
295
+ @property
296
+ def name(self) -> str:
297
+ return self.metadata.name
298
+
299
+ # ---------- Public entry points ----------
300
+
301
+ def detect(self, repo_path: str | Path) -> bool:
302
+ root = Path(repo_path).resolve()
303
+ if not root.exists() or not root.is_dir():
304
+ return False
305
+ # One walk. Any C++ source/header claims the repo; so do .h headers in
306
+ # a CMake project that enables CXX (header-only C++ with .h names).
307
+ # Suffixes are matched case-sensitively, as before; the SDK match is
308
+ # case-insensitive, so filter again.
309
+ has_header = False
310
+ cmake_cxx = False
311
+ for path in iter_repo_files(
312
+ root, suffixes=CODE_SUFFIXES | {_HEADER_SUFFIX}, names={"CMakeLists.txt"}, skip_dirs=SKIP_DIRS
313
+ ):
314
+ if path.suffix in CODE_SUFFIXES:
315
+ return True
316
+ if path.name == "CMakeLists.txt":
317
+ text = read_source(path)
318
+ cmake_cxx = cmake_cxx or (text is not None and _CMAKE_CXX_PATTERN.search(text) is not None)
319
+ elif path.suffix == _HEADER_SUFFIX:
320
+ has_header = True
321
+ return has_header and cmake_cxx
322
+
323
+ def analyze(self, repo_path: str | Path) -> ScanResult:
324
+ root = Path(repo_path).resolve()
325
+ result = ScanResult(root=str(root))
326
+ if not root.exists() or not root.is_dir():
327
+ return result
328
+
329
+ # One walk collects sources, headers and CMake files; .h headers are
330
+ # analyzed only when the repo is C++ (see the module-level rule).
331
+ candidates: list[Path] = []
332
+ cxx_repo = False
333
+ for file_path in iter_repo_files(
334
+ root,
335
+ suffixes=CODE_SUFFIXES | {_HEADER_SUFFIX} | MANIFEST_SUFFIXES,
336
+ names={"CMakeLists.txt"} | MANIFEST_NAMES,
337
+ skip_dirs=SKIP_DIRS,
338
+ ):
339
+ if is_manifest(file_path):
340
+ # CMake / Conan / vcpkg dependencies and unpinned CMake
341
+ # downloads (AttackMap#191).
342
+ text = read_source(file_path)
343
+ if text is None:
344
+ continue
345
+ dependencies, supply_chain = manifest_records(file_path, text, rel(file_path, root))
346
+ result.dependencies.extend(dependencies)
347
+ result.supply_chain_issues.extend(supply_chain)
348
+ if file_path.name != "CMakeLists.txt":
349
+ continue
350
+ project = _project_name_from_cmake(text)
351
+ if project:
352
+ self._append_unique_service(result, f"project:{project}", rel(file_path, root))
353
+ if _CMAKE_CXX_PATTERN.search(text):
354
+ cxx_repo = True
355
+ continue
356
+ if file_path.suffix in _CXX_MARKER_SUFFIXES:
357
+ cxx_repo = True
358
+ candidates.append(file_path)
359
+ elif file_path.suffix == _HEADER_SUFFIX:
360
+ candidates.append(file_path)
361
+
362
+ for file_path in candidates:
363
+ if file_path.suffix == _HEADER_SUFFIX and not cxx_repo:
364
+ continue # owned by the C analyzer
365
+ content = read_source(file_path)
366
+ if content is None:
367
+ continue
368
+
369
+ result.files_scanned += 1
370
+ if "cpp" not in result.languages:
371
+ result.languages.append("cpp")
372
+
373
+ relative = rel(file_path, root)
374
+ self._extract_routes(content, relative, result)
375
+ self._extract_databases(content, relative, result)
376
+ self._extract_auth(content, relative, result)
377
+ self._extract_secrets(content, relative, result)
378
+ self._extract_external_calls(content, relative, result)
379
+ self._extract_frameworks(content, relative, result)
380
+ self._extract_entrypoints(content, relative, result)
381
+ result.code_weaknesses.extend(find_native_weaknesses(content, relative))
382
+
383
+ result.dependencies = dedupe_dependencies(result.dependencies)
384
+
385
+ result.languages.sort()
386
+ return result
387
+
388
+ # ---------- Extractors ----------
389
+
390
+ def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
391
+ # Crow: CROW_ROUTE(app, "/x").methods("POST"_method, "PUT"_method)
392
+ for match in CROW_ROUTE_PATTERN.finditer(content):
393
+ path = match.group(1)
394
+ chain = match.group("chain") or ""
395
+ line = line_of(content, match.start())
396
+ methods: set[str] = set()
397
+ methods_match = CROW_METHODS_PATTERN.search(chain)
398
+ if methods_match:
399
+ methods = {m.upper() for m in re.findall(r'"([A-Z]+)"_method', methods_match.group(1))}
400
+ if not methods:
401
+ methods = {"ANY"}
402
+ for method in sorted(methods):
403
+ self._append_unique_route(result, path, method, relative, line)
404
+
405
+ # Pistache: Routes::Get(router, "/x", handler)
406
+ for match in PISTACHE_ROUTE_PATTERN.finditer(content):
407
+ method, path = match.group(1).upper(), match.group(2)
408
+ self._append_unique_route(result, path, method, relative, line_of(content, match.start()))
409
+
410
+ # Drogon registerHandler with explicit method list: {Drogon::Get, Drogon::Post}
411
+ for match in DROGON_REGISTER_PATTERN.finditer(content):
412
+ path = match.group(1)
413
+ methods_blob = match.group(2)
414
+ methods = {
415
+ m.upper()
416
+ for m in re.findall(r'\b(?:Drogon::)?(Get|Post|Put|Delete|Patch|Head|Options)\b', methods_blob)
417
+ }
418
+ line = line_of(content, match.start())
419
+ if not methods:
420
+ methods = {"ANY"}
421
+ for method in sorted(methods):
422
+ self._append_unique_route(result, path, method, relative, line)
423
+
424
+ # Drogon ADD_METHOD_TO(ctrl::handler, "/abs/path", Get, Post)
425
+ for match in DROGON_ADD_METHOD_PATTERN.finditer(content):
426
+ line = line_of(content, match.start())
427
+ for method in _drogon_methods(match.group(2)):
428
+ self._append_unique_route(result, match.group(1) or "/", method, relative, line)
429
+
430
+ # Drogon METHOD_ADD(Ctrl::handler, "/rel", Get): path is relative to
431
+ # the controller's /namespace/ClassName path.
432
+ if "METHOD_ADD" in content:
433
+ controllers = _drogon_controller_paths(content)
434
+ for match in DROGON_METHOD_ADD_PATTERN.finditer(content):
435
+ qualifier = match.group(1) or ""
436
+ class_name = qualifier.split("::")[-1] if qualifier else ""
437
+ prefix = controllers.get(class_name)
438
+ if prefix is None:
439
+ if len(controllers) == 1:
440
+ prefix = next(iter(controllers.values()))
441
+ elif qualifier:
442
+ # Controller declared elsewhere; best effort from the
443
+ # qualified handler name (ns::Class::fn).
444
+ prefix = "/" + qualifier.replace("::", "/")
445
+ else:
446
+ continue
447
+ path = _join_drogon_path(prefix, match.group(2))
448
+ line = line_of(content, match.start())
449
+ for method in _drogon_methods(match.group(3)):
450
+ self._append_unique_route(result, path, method, relative, line)
451
+
452
+ # Drogon HttpSimpleController PATH_ADD("/abs/path", Get)
453
+ for match in DROGON_PATH_ADD_PATTERN.finditer(content):
454
+ line = line_of(content, match.start())
455
+ for method in _drogon_methods(match.group(2)):
456
+ self._append_unique_route(result, match.group(1) or "/", method, relative, line)
457
+
458
+ # cpprestsdk listener URL — the listener is constructed with the full URL
459
+ # which we treat as both an entrypoint and a route (path part).
460
+ for match in CPPRESTSDK_LISTENER_PATTERN.finditer(content):
461
+ url = match.group(1)
462
+ # Best-effort: extract the path component of the URL.
463
+ path_match = re.search(r"https?://[^/]+(/[^?]*)", url)
464
+ if path_match:
465
+ path = path_match.group(1) or "/"
466
+ self._append_unique_route(result, path, "ANY", relative, line_of(content, match.start()))
467
+
468
+ # cpp-httplib / oat++ / restbed (AttackMap#191), with Route.auth where
469
+ # the framework shows a guard.
470
+ for route in framework_routes(content, relative):
471
+ key = (route.path, route.method, route.file)
472
+ if not any((item.path, item.method, item.file) == key for item in result.routes):
473
+ result.routes.append(route)
474
+
475
+ def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
476
+ for pattern, kind in DB_PATTERNS:
477
+ match = pattern.search(content)
478
+ if match is None:
479
+ continue
480
+ self._append_unique_database(
481
+ result, kind, relative,
482
+ line_of(content, match.start()),
483
+ _line_snippet(content, match.start()),
484
+ )
485
+
486
+ def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
487
+ for pattern, hint, confidence in AUTH_PATTERNS:
488
+ match = pattern.search(content)
489
+ if match is None:
490
+ continue
491
+ self._append_unique_auth(
492
+ result, hint, relative,
493
+ line_of(content, match.start()),
494
+ _line_snippet(content, match.start()),
495
+ confidence,
496
+ )
497
+
498
+ def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
499
+ for pattern in SECRET_PATTERNS:
500
+ for match in pattern.finditer(content):
501
+ name = match.group(1)
502
+ self._append_unique_secret(
503
+ result, name, relative,
504
+ line_of(content, match.start()),
505
+ _line_snippet(content, match.start()),
506
+ )
507
+
508
+ def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
509
+ for pattern in OUTBOUND_PATTERNS:
510
+ for match in pattern.finditer(content):
511
+ target = match.group(1)
512
+ if not (target.startswith("http://") or target.startswith("https://")):
513
+ continue
514
+ self._append_unique_external(
515
+ result, target, relative,
516
+ line_of(content, match.start()),
517
+ _line_snippet(content, match.start()),
518
+ )
519
+
520
+ def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
521
+ for pattern, name in FRAMEWORK_PATTERNS:
522
+ match = pattern.search(content)
523
+ if match is None:
524
+ continue
525
+ self._append_unique_framework(
526
+ result, name, relative,
527
+ line_of(content, match.start()),
528
+ _line_snippet(content, match.start()),
529
+ )
530
+
531
+ def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
532
+ for pattern, hint in ENTRYPOINT_PATTERNS:
533
+ match = pattern.search(content)
534
+ if match is None:
535
+ continue
536
+ self._append_unique_entrypoint(
537
+ result, hint, relative,
538
+ line_of(content, match.start()),
539
+ _line_snippet(content, match.start()),
540
+ )
541
+
542
+ # ---------- Append helpers ----------
543
+
544
+ @staticmethod
545
+ def _append_unique_route(result: ScanResult, path: str, method: str, file: str, line: int | None) -> None:
546
+ key = (path, method, file)
547
+ if any((item.path, item.method, item.file) == key for item in result.routes):
548
+ return
549
+ result.routes.append(Route(path=path, method=method, file=file, line=line))
550
+
551
+ @staticmethod
552
+ def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
553
+ key = (kind, file)
554
+ if any((item.kind, item.file) == key for item in result.databases):
555
+ return
556
+ result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
557
+
558
+ @staticmethod
559
+ def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
560
+ key = (hint, file)
561
+ if any((item.hint, item.file) == key for item in result.auth_hints):
562
+ return
563
+ result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
564
+
565
+ @staticmethod
566
+ def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
567
+ key = (name, file)
568
+ if any((item.name, item.file) == key for item in result.secret_hints):
569
+ return
570
+ result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
571
+
572
+ @staticmethod
573
+ def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
574
+ key = (target, file)
575
+ if any((item.target, item.file) == key for item in result.external_calls):
576
+ return
577
+ result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
578
+
579
+ @staticmethod
580
+ def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
581
+ key = (hint, file)
582
+ if any((item.hint, item.file) == key for item in result.framework_hints):
583
+ return
584
+ result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
585
+
586
+ @staticmethod
587
+ def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
588
+ key = (hint, file)
589
+ if any((item.hint, item.file) == key for item in result.entrypoint_hints):
590
+ return
591
+ result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
592
+
593
+ @staticmethod
594
+ def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
595
+ key = (hint, file)
596
+ if any((item.hint, item.file) == key for item in result.service_hints):
597
+ return
598
+ result.service_hints.append(ServiceHint(hint=hint, file=file))
599
+
600
+
601
+ __all__ = ["CppAnalyzer"]