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.
- attackmap_analyzer_cpp/__init__.py +3 -0
- attackmap_analyzer_cpp/analyzer.py +601 -0
- attackmap_analyzer_cpp/framework_routes.py +255 -0
- attackmap_analyzer_cpp/native_deps.py +524 -0
- attackmap_analyzer_cpp/native_sinks.py +1737 -0
- attackmap_analyzer_cpp-0.2.0.dist-info/METADATA +170 -0
- attackmap_analyzer_cpp-0.2.0.dist-info/RECORD +11 -0
- attackmap_analyzer_cpp-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_cpp-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_cpp-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_cpp-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|