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.
@@ -0,0 +1,3 @@
1
+ from .analyzer import CAnalyzer
2
+
3
+ __all__ = ["CAnalyzer"]
@@ -0,0 +1,431 @@
1
+ """C ecosystem analyzer for AttackMap.
2
+
3
+ Coverage (v0.1):
4
+ - Web frameworks: civetweb (mg_set_request_handler routes), libmicrohttpd
5
+ (MHD_start_daemon entrypoint), mongoose (mg_http_listen entrypoint +
6
+ mg_http_match_uri pseudo-routes), libonion (onion_url_add routes)
7
+ - HTTP clients (external calls): libcurl (CURLOPT_URL string literals)
8
+ - Databases: sqlite3 (sqlite3_open*), libpq (PQconnectdb*), MySQL/MariaDB C
9
+ client (mysql_real_connect), hiredis (redisConnect*), MongoDB C driver
10
+ (mongoc_client_new)
11
+ - Auth/crypto: OpenSSL (broad framework hint via EVP_/SSL_/RSA_), mbedTLS,
12
+ libsodium (crypto_pwhash, crypto_secretbox), Argon2 reference impl,
13
+ bcrypt-c, scrypt
14
+ - Secrets: getenv / secure_getenv / getenv_s with secret-shaped names
15
+ - Service hints: project name from CMakeLists.txt
16
+ - Dependencies (AttackMap#191): CMake FetchContent / ExternalProject / CPM /
17
+ find_package, Conan and vcpkg manifests (see ``native_deps``, shared
18
+ verbatim with attackmap-analyzer-cpp)
19
+ - Native sinks (AttackMap#189): unbounded copies, non-constant formats,
20
+ command execution, input-controlled copy lengths and allocation-size
21
+ overflows, emitted as ``CodeWeakness`` records (see ``native_sinks``)
22
+
23
+ C is more fragmented than other ecosystems — there's no single dominant
24
+ HTTP framework, and routing patterns vary widely. We capture what regex
25
+ can reach reliably and conservatively confidence-tag pattern hits.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from pathlib import Path
32
+
33
+ from attackmap.sdk import (
34
+ DEFAULT_SKIP_DIRS,
35
+ AnalyzerMetadata,
36
+ AuthHint,
37
+ DatabaseHint,
38
+ EntrypointHint,
39
+ ExternalCall,
40
+ FrameworkHint,
41
+ iter_repo_files,
42
+ line_of,
43
+ read_source,
44
+ rel,
45
+ Route,
46
+ ScanResult,
47
+ SecretHint,
48
+ ServiceHint,
49
+ )
50
+
51
+ from .native_deps import MANIFEST_NAMES, MANIFEST_SUFFIXES, dedupe_dependencies, is_manifest, manifest_records
52
+ from .native_sinks import find_native_weaknesses
53
+
54
+ CODE_SUFFIXES = {".c", ".h"}
55
+ # C-specific additions to the shared skip list (which already covers build/,
56
+ # out/, vendor/, node_modules/, .git/, ...). Matched against directory names
57
+ # *inside* the repo only.
58
+ SKIP_DIRS = DEFAULT_SKIP_DIRS | {"_deps", "third_party", "external", ".cache"}
59
+
60
+ # ---------- .h ownership (shared rule with attackmap-analyzer-cpp) ----------
61
+ #
62
+ # A ``.h`` header is either C or C++, and only one analyzer may claim it, or a
63
+ # C++ project's headers (e.g. Drogon controllers, whose routes live in the
64
+ # header) get analyzed as C and labelled language ``c``. The rule is decided
65
+ # per repo and is mirrored *verbatim* in attackmap-analyzer-cpp, so both
66
+ # plugins agree whichever of ``-m c`` / ``-m cpp`` is selected:
67
+ #
68
+ # ``.h`` belongs to C++ if the repo has any C++ source/header (the suffixes
69
+ # below) or a CMakeLists.txt that enables CXX (``project(... CXX ...)``,
70
+ # ``enable_language(CXX)`` or ``CMAKE_CXX_STANDARD``); otherwise to C.
71
+ #
72
+ # Keep ``_CXX_MARKER_SUFFIXES``, ``_CMAKE_CXX_PATTERN`` and
73
+ # ``_OWNERSHIP_EXTRA_SKIP_DIRS`` in sync with the cpp plugin.
74
+ _CXX_MARKER_SUFFIXES = {".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".ipp", ".tpp"}
75
+ _CMAKE_CXX_PATTERN = re.compile(
76
+ r"\b(?i:project)\s*\([^)]*\bCXX\b"
77
+ r"|\b(?i:enable_language)\s*\(\s*CXX\b"
78
+ r"|\bCMAKE_CXX_STANDARD\b",
79
+ )
80
+ # The cpp plugin also prunes these; ignore C++ markers under them here too so
81
+ # both plugins see the same marker set.
82
+ _OWNERSHIP_EXTRA_SKIP_DIRS = {"Debug", "Release"}
83
+ _SNIPPET_MAX_CHARS = 160
84
+
85
+
86
+ # ---------- Patterns ----------
87
+
88
+ # civetweb: mg_set_request_handler(ctx, "/path", handler, NULL)
89
+ CIVETWEB_ROUTE_PATTERN = re.compile(
90
+ r'\bmg_set_request_handler\s*\(\s*\w+\s*,\s*"([^"]+)"',
91
+ )
92
+
93
+ # mongoose: mg_http_match_uri(hm, "/path") inside an event handler — pseudo-routes
94
+ MONGOOSE_MATCH_URI_PATTERN = re.compile(
95
+ r'\bmg_http_match_uri\s*\(\s*\w+\s*,\s*"([^"]+)"',
96
+ )
97
+
98
+ # libonion: onion_url_add(url, "^/path/?$", handler, ...)
99
+ ONION_ROUTE_PATTERN = re.compile(
100
+ r'\bonion_url_add(?:_static|_with_data)?\s*\(\s*\w+\s*,\s*"([^"]+)"',
101
+ )
102
+
103
+ # libcurl: curl_easy_setopt(handle, CURLOPT_URL, "https://...")
104
+ LIBCURL_URL_PATTERN = re.compile(
105
+ r'\bcurl_easy_setopt\s*\(\s*\w+\s*,\s*CURLOPT_URL\s*,\s*"(https?://[^"]+)"',
106
+ )
107
+
108
+ # DBs
109
+ DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
110
+ (re.compile(r'\bsqlite3_open(?:_v2)?\s*\('), "sqlite"),
111
+ (re.compile(r'\bPQconnectdb(?:Params)?\s*\(|\bPQsetdbLogin\s*\('), "postgresql"),
112
+ (re.compile(r'\bmysql_real_connect\s*\(|\bmariadb_real_connect\s*\('), "mysql"),
113
+ (re.compile(r'\bredisConnect(?:WithTimeout)?\s*\('), "redis"),
114
+ (re.compile(r'\bmongoc_client_new\s*\(|\bmongoc_uri_new\s*\('), "mongodb"),
115
+ ]
116
+
117
+ # Auth / crypto
118
+ AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
119
+ (re.compile(r'\bcrypto_pwhash(?:_argon2(?:i|id))?\s*\(|\bcrypto_pwhash_str\s*\('), "argon2", 0.9),
120
+ (re.compile(r'\bargon2(?:i|d|id)?_hash\w*\s*\('), "argon2", 0.9),
121
+ (re.compile(r'\bbcrypt(?:_hashpw|_checkpw|_gensalt)?\s*\('), "bcrypt", 0.9),
122
+ (re.compile(r'\bscrypt(?:_kdf)?\s*\('), "scrypt", 0.9),
123
+ (re.compile(r'\bcrypto_secretbox\w*\s*\(|\bcrypto_aead_(?:chacha20poly1305|aes256gcm)\w*\s*\('), "libsodium_aead", 0.85),
124
+ (re.compile(r'\bSSL_CTX_new\s*\(|\bSSL_new\s*\(|\bTLS_method\s*\(|\bTLS_(?:client|server)_method\s*\('), "openssl_tls", 0.85),
125
+ (re.compile(r'\bEVP_PKEY_new\s*\(|\bEVP_(?:Encrypt|Decrypt)Init\w*\s*\('), "openssl_evp", 0.8),
126
+ (re.compile(r'\bRAND_bytes\s*\(|\bRAND_priv_bytes\s*\('), "openssl_rand", 0.8),
127
+ (re.compile(r'\bmbedtls_(?:ssl|x509)_\w+\s*\('), "mbedtls", 0.85),
128
+ (re.compile(r'\bjwt_(?:encode|decode|new|verify)\s*\('), "jwt", 0.85),
129
+ (re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
130
+ (re.compile(r'\bBearer\b'), "bearer_token", 0.6),
131
+ (re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
132
+ ]
133
+
134
+ FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
135
+ (re.compile(r'\bMHD_start_daemon\s*\(|\bMHD_create_response\w*\s*\(|#\s*include\s+["<]microhttpd\.h'), "libmicrohttpd"),
136
+ (re.compile(r'\bmg_start\s*\(|\bmg_set_request_handler\s*\(|#\s*include\s+["<]civetweb\.h'), "civetweb"),
137
+ (re.compile(r'\bmg_mgr_init\s*\(|\bmg_http_listen\s*\(|#\s*include\s+["<]mongoose\.h'), "mongoose"),
138
+ (re.compile(r'\bonion_new\s*\(|\bonion_listen\s*\(|#\s*include\s+["<]onion/onion\.h'), "libonion"),
139
+ (re.compile(r'#\s*include\s+["<]openssl/'), "openssl"),
140
+ (re.compile(r'#\s*include\s+["<]mbedtls/'), "mbedtls"),
141
+ (re.compile(r'#\s*include\s+["<]sodium\.h'), "libsodium"),
142
+ (re.compile(r'#\s*include\s+["<]curl/curl\.h'), "libcurl"),
143
+ (re.compile(r'#\s*include\s+["<]sqlite3\.h'), "sqlite3"),
144
+ (re.compile(r'#\s*include\s+["<]libpq-fe\.h'), "libpq"),
145
+ (re.compile(r'#\s*include\s+["<]hiredis/hiredis\.h'), "hiredis"),
146
+ (re.compile(r'#\s*include\s+["<]mongoc/mongoc\.h|#\s*include\s+["<]mongoc\.h'), "mongoc"),
147
+ ]
148
+
149
+ ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
150
+ (re.compile(r'\bMHD_start_daemon\s*\('), "mhd_start_daemon"),
151
+ (re.compile(r'\bmg_start\s*\('), "civetweb_start"),
152
+ (re.compile(r'\bmg_http_listen\s*\('), "mongoose_http_listen"),
153
+ (re.compile(r'\bonion_listen\s*\('), "onion_listen"),
154
+ (re.compile(r'\baccept\s*\(\s*\w+\s*,\s*\(struct\s+sockaddr\s*\*\)'), "raw_socket_accept"),
155
+ ]
156
+
157
+ # Secrets via getenv / secure_getenv / getenv_s
158
+ SECRET_PATTERNS: list[re.Pattern[str]] = [
159
+ re.compile(
160
+ r'\b(?:secure_)?getenv\s*\(\s*"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
161
+ ),
162
+ re.compile(
163
+ r'\bgetenv_s\s*\([^,]*,\s*[^,]*,\s*"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
164
+ ),
165
+ ]
166
+
167
+
168
+ def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
169
+ # Kept local rather than ``attackmap.sdk.line_snippet(content, line_of(...))``:
170
+ # the SDK helper indexes ``str.splitlines()``, which also breaks on form
171
+ # feeds (common in legacy C) and lone ``\r``, so its line numbering can
172
+ # disagree with ``line_of`` (which counts ``\n`` only).
173
+ line_start = content.rfind("\n", 0, offset) + 1
174
+ line_end = content.find("\n", offset)
175
+ if line_end == -1:
176
+ line_end = len(content)
177
+ line = content[line_start:line_end].strip()
178
+ if len(line) > max_chars:
179
+ line = line[: max_chars - 1] + "…"
180
+ return line
181
+
182
+
183
+ def _project_name_from_cmake(text: str) -> str | None:
184
+ match = re.search(r"\bproject\s*\(\s*([A-Za-z0-9_\-]+)", text)
185
+ if match:
186
+ return match.group(1)
187
+ return None
188
+
189
+
190
+ def _counts_for_ownership(path: Path, root: Path) -> bool:
191
+ parents = Path(rel(path, root)).parts[:-1]
192
+ return not any(part in _OWNERSHIP_EXTRA_SKIP_DIRS for part in parents)
193
+
194
+
195
+ class CAnalyzer:
196
+ metadata = AnalyzerMetadata(
197
+ name="c",
198
+ display_name="C Analyzer",
199
+ version="0.2.0",
200
+ description="C ecosystem analyzer covering libmicrohttpd, civetweb, mongoose, libonion, libcurl, OpenSSL, libsodium, sqlite3, libpq, mysql, hiredis, mongoc, plus memory-safety and command-exec sinks.",
201
+ scope="C source trees and CMake/Make projects. Detects HTTP server entrypoints, libcurl outbound calls, common DB and crypto libraries, and function-local memory-safety / format-string / command-exec sinks tied to request and input sources.",
202
+ targets=["c", "civetweb", "libmicrohttpd", "mongoose"],
203
+ languages=["c"],
204
+ priority=20,
205
+ experimental=True, # C ecosystem coverage is heuristic and partial; mark experimental.
206
+ enabled_by_default=False, # opt-in via `-m c` while experimental (AttackMap#221)
207
+ )
208
+
209
+ @property
210
+ def name(self) -> str:
211
+ return self.metadata.name
212
+
213
+ # ---------- Public entry points ----------
214
+
215
+ def detect(self, repo_path: str | Path) -> bool:
216
+ root = Path(repo_path).resolve()
217
+ if not root.exists() or not root.is_dir():
218
+ return False
219
+ # One walk; at least one ``.c`` source claims the repo. A lone ``.h``
220
+ # (an ObjC/Swift bridging header, a Python C-extension header) or a
221
+ # CMakeLists.txt/Makefile on its own does not. Suffixes are matched
222
+ # case-sensitively (``.C`` is a C++ convention), so filter after the
223
+ # case-insensitive SDK match.
224
+ for path in iter_repo_files(root, suffixes={".c"}, skip_dirs=SKIP_DIRS):
225
+ if path.suffix == ".c":
226
+ return True
227
+ return False
228
+
229
+ def analyze(self, repo_path: str | Path) -> ScanResult:
230
+ root = Path(repo_path).resolve()
231
+ result = ScanResult(root=str(root))
232
+ if not root.exists() or not root.is_dir():
233
+ return result
234
+
235
+ # One walk collects sources, CMake files and the C++ markers that
236
+ # decide .h ownership (see the module-level rule above).
237
+ sources: list[Path] = []
238
+ cxx_repo = False
239
+ for file_path in iter_repo_files(
240
+ root,
241
+ suffixes=CODE_SUFFIXES | _CXX_MARKER_SUFFIXES | MANIFEST_SUFFIXES,
242
+ names={"CMakeLists.txt"} | MANIFEST_NAMES,
243
+ skip_dirs=SKIP_DIRS,
244
+ ):
245
+ if is_manifest(file_path):
246
+ # CMake / Conan / vcpkg dependencies and unpinned CMake
247
+ # downloads (AttackMap#191).
248
+ text = read_source(file_path)
249
+ if text is None:
250
+ continue
251
+ dependencies, supply_chain = manifest_records(file_path, text, rel(file_path, root))
252
+ result.dependencies.extend(dependencies)
253
+ result.supply_chain_issues.extend(supply_chain)
254
+ if file_path.name != "CMakeLists.txt":
255
+ continue
256
+ project = _project_name_from_cmake(text)
257
+ if project:
258
+ self._append_unique_service(result, f"project:{project}", rel(file_path, root))
259
+ if _CMAKE_CXX_PATTERN.search(text) and _counts_for_ownership(file_path, root):
260
+ cxx_repo = True
261
+ continue
262
+ if file_path.suffix in _CXX_MARKER_SUFFIXES:
263
+ if _counts_for_ownership(file_path, root):
264
+ cxx_repo = True
265
+ continue
266
+ if file_path.suffix in CODE_SUFFIXES:
267
+ sources.append(file_path)
268
+
269
+ for file_path in sources:
270
+ if file_path.suffix == ".h" and cxx_repo:
271
+ continue # owned by the C++ analyzer
272
+ content = read_source(file_path)
273
+ if content is None:
274
+ continue
275
+
276
+ result.files_scanned += 1
277
+ if "c" not in result.languages:
278
+ result.languages.append("c")
279
+
280
+ relative = rel(file_path, root)
281
+ self._extract_routes(content, relative, result)
282
+ self._extract_databases(content, relative, result)
283
+ self._extract_auth(content, relative, result)
284
+ self._extract_secrets(content, relative, result)
285
+ self._extract_external_calls(content, relative, result)
286
+ self._extract_frameworks(content, relative, result)
287
+ self._extract_entrypoints(content, relative, result)
288
+ result.code_weaknesses.extend(find_native_weaknesses(content, relative))
289
+
290
+ result.dependencies = dedupe_dependencies(result.dependencies)
291
+
292
+ result.languages.sort()
293
+ return result
294
+
295
+ # ---------- Extractors ----------
296
+
297
+ def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
298
+ for match in CIVETWEB_ROUTE_PATTERN.finditer(content):
299
+ path = match.group(1)
300
+ self._append_unique_route(result, path, "ANY", relative, line_of(content, match.start()))
301
+ for match in MONGOOSE_MATCH_URI_PATTERN.finditer(content):
302
+ path = match.group(1)
303
+ self._append_unique_route(result, path, "ANY", relative, line_of(content, match.start()))
304
+ for match in ONION_ROUTE_PATTERN.finditer(content):
305
+ path = match.group(1)
306
+ self._append_unique_route(result, path, "ANY", relative, line_of(content, match.start()))
307
+
308
+ def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
309
+ for pattern, kind in DB_PATTERNS:
310
+ match = pattern.search(content)
311
+ if match is None:
312
+ continue
313
+ self._append_unique_database(
314
+ result, kind, relative,
315
+ line_of(content, match.start()),
316
+ _line_snippet(content, match.start()),
317
+ )
318
+
319
+ def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
320
+ for pattern, hint, confidence in AUTH_PATTERNS:
321
+ match = pattern.search(content)
322
+ if match is None:
323
+ continue
324
+ self._append_unique_auth(
325
+ result, hint, relative,
326
+ line_of(content, match.start()),
327
+ _line_snippet(content, match.start()),
328
+ confidence,
329
+ )
330
+
331
+ def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
332
+ for pattern in SECRET_PATTERNS:
333
+ for match in pattern.finditer(content):
334
+ name = match.group(1)
335
+ self._append_unique_secret(
336
+ result, name, relative,
337
+ line_of(content, match.start()),
338
+ _line_snippet(content, match.start()),
339
+ )
340
+
341
+ def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
342
+ for match in LIBCURL_URL_PATTERN.finditer(content):
343
+ target = match.group(1)
344
+ self._append_unique_external(
345
+ result, target, relative,
346
+ line_of(content, match.start()),
347
+ _line_snippet(content, match.start()),
348
+ )
349
+
350
+ def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
351
+ for pattern, name in FRAMEWORK_PATTERNS:
352
+ match = pattern.search(content)
353
+ if match is None:
354
+ continue
355
+ self._append_unique_framework(
356
+ result, name, relative,
357
+ line_of(content, match.start()),
358
+ _line_snippet(content, match.start()),
359
+ )
360
+
361
+ def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
362
+ for pattern, hint in ENTRYPOINT_PATTERNS:
363
+ match = pattern.search(content)
364
+ if match is None:
365
+ continue
366
+ self._append_unique_entrypoint(
367
+ result, hint, relative,
368
+ line_of(content, match.start()),
369
+ _line_snippet(content, match.start()),
370
+ )
371
+
372
+ # ---------- Append helpers ----------
373
+
374
+ @staticmethod
375
+ def _append_unique_route(result: ScanResult, path: str, method: str, file: str, line: int | None) -> None:
376
+ key = (path, method, file)
377
+ if any((item.path, item.method, item.file) == key for item in result.routes):
378
+ return
379
+ result.routes.append(Route(path=path, method=method, file=file, line=line))
380
+
381
+ @staticmethod
382
+ def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
383
+ key = (kind, file)
384
+ if any((item.kind, item.file) == key for item in result.databases):
385
+ return
386
+ result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
387
+
388
+ @staticmethod
389
+ def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
390
+ key = (hint, file)
391
+ if any((item.hint, item.file) == key for item in result.auth_hints):
392
+ return
393
+ result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
394
+
395
+ @staticmethod
396
+ def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
397
+ key = (name, file)
398
+ if any((item.name, item.file) == key for item in result.secret_hints):
399
+ return
400
+ result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
401
+
402
+ @staticmethod
403
+ def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
404
+ key = (target, file)
405
+ if any((item.target, item.file) == key for item in result.external_calls):
406
+ return
407
+ result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
408
+
409
+ @staticmethod
410
+ def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
411
+ key = (hint, file)
412
+ if any((item.hint, item.file) == key for item in result.framework_hints):
413
+ return
414
+ result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
415
+
416
+ @staticmethod
417
+ def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
418
+ key = (hint, file)
419
+ if any((item.hint, item.file) == key for item in result.entrypoint_hints):
420
+ return
421
+ result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
422
+
423
+ @staticmethod
424
+ def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
425
+ key = (hint, file)
426
+ if any((item.hint, item.file) == key for item in result.service_hints):
427
+ return
428
+ result.service_hints.append(ServiceHint(hint=hint, file=file))
429
+
430
+
431
+ __all__ = ["CAnalyzer"]