Guest User

Untitled

a guest
May 11th, 2026
214
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 17.55 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. Deep source audit helper for Metin2 server sources.
  4.  
  5. Read-only scanner. It does not modify the source tree.
  6.  
  7. Usage:
  8. python deep_source_audit.py "H:\\Project\\sources\\Server"
  9. python deep_source_audit.py "H:\\Project\\sources\\Server" --min MEDIUM
  10. """
  11.  
  12. from __future__ import annotations
  13.  
  14. import argparse
  15. import json
  16. import os
  17. import re
  18. import sys
  19. import time
  20. from dataclasses import asdict, dataclass
  21. from pathlib import Path
  22. from typing import Iterable
  23.  
  24.  
  25. SEVERITY_ORDER = {
  26. "INFO": 0,
  27. "LOW": 1,
  28. "MEDIUM": 2,
  29. "HIGH": 3,
  30. "CRITICAL": 4,
  31. }
  32.  
  33. SCAN_EXTENSIONS = {
  34. ".c",
  35. ".cc",
  36. ".cpp",
  37. ".cxx",
  38. ".h",
  39. ".hh",
  40. ".hpp",
  41. ".lua",
  42. ".py",
  43. ".sql",
  44. ".txt",
  45. ".json",
  46. ".xml",
  47. ".conf",
  48. ".config",
  49. }
  50.  
  51. SKIP_DIRS = {
  52. ".git",
  53. ".svn",
  54. ".hg",
  55. ".vs",
  56. ".vscode",
  57. "__pycache__",
  58. "build",
  59. "cmake-build-debug",
  60. "cmake-build-release",
  61. "debug",
  62. "release",
  63. "obj",
  64. "bin",
  65. "lib",
  66. "libs",
  67. "extern",
  68. "external",
  69. "thirdparty",
  70. "third_party",
  71. }
  72.  
  73. SQL_KEYWORDS = re.compile(
  74. r"\b(SELECT|INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|TRUNCATE)\b",
  75. re.IGNORECASE,
  76. )
  77.  
  78.  
  79. @dataclass
  80. class Finding:
  81. rule_id: str
  82. severity: str
  83. confidence: str
  84. title: str
  85. file: str
  86. line: int
  87. evidence: str
  88. why: str
  89. remediation: str
  90.  
  91.  
  92. def read_text(path: Path) -> str:
  93. try:
  94. return path.read_text(encoding="utf-8", errors="ignore")
  95. except OSError:
  96. return ""
  97.  
  98.  
  99. def iter_files(root: Path) -> Iterable[Path]:
  100. for dirpath, dirnames, filenames in os.walk(root):
  101. dirnames[:] = [d for d in dirnames if d.lower() not in SKIP_DIRS]
  102. for name in filenames:
  103. path = Path(dirpath) / name
  104. if path.suffix.lower() in SCAN_EXTENSIONS:
  105. yield path
  106.  
  107.  
  108. def line_number(text: str, offset: int) -> int:
  109. return text.count("\n", 0, offset) + 1
  110.  
  111.  
  112. def get_line(lines: list[str], line: int) -> str:
  113. if 1 <= line <= len(lines):
  114. return lines[line - 1].rstrip("\n")
  115. return ""
  116.  
  117.  
  118. def context(lines: list[str], line: int, radius: int = 2) -> str:
  119. start = max(1, line - radius)
  120. end = min(len(lines), line + radius)
  121. out = []
  122. for num in range(start, end + 1):
  123. marker = ">" if num == line else " "
  124. out.append(f"{marker}{num:5}: {get_line(lines, num)}")
  125. return "\n".join(out)
  126.  
  127.  
  128. def statement_from(lines: list[str], start_line: int, max_lines: int = 8) -> str:
  129. parts = []
  130. for i in range(start_line - 1, min(len(lines), start_line - 1 + max_lines)):
  131. parts.append(lines[i].rstrip("\n"))
  132. if ";" in lines[i]:
  133. break
  134. return "\n".join(parts)
  135.  
  136.  
  137. def nearby_text(lines: list[str], line: int, before: int = 30, after: int = 8) -> str:
  138. start = max(1, line - before)
  139. end = min(len(lines), line + after)
  140. return "\n".join(lines[start - 1 : end])
  141.  
  142.  
  143. def add_finding(
  144. findings: list[Finding],
  145. rule_id: str,
  146. severity: str,
  147. confidence: str,
  148. title: str,
  149. file: Path,
  150. line: int,
  151. evidence: str,
  152. why: str,
  153. remediation: str,
  154. ) -> None:
  155. findings.append(
  156. Finding(
  157. rule_id=rule_id,
  158. severity=severity,
  159. confidence=confidence,
  160. title=title,
  161. file=str(file),
  162. line=line,
  163. evidence=evidence.strip(),
  164. why=why.strip(),
  165. remediation=remediation.strip(),
  166. )
  167. )
  168.  
  169.  
  170. def looks_like_fixed_literal(arg: str) -> bool:
  171. arg = arg.strip()
  172. if arg.endswith(")"):
  173. arg = arg[:-1].strip()
  174. return bool(re.fullmatch(r'L?"[^"]*"\s*', arg))
  175.  
  176.  
  177. def contains_escape(window: str) -> bool:
  178. return bool(
  179. re.search(
  180. r"\b(EscapeString|mysql_real_escape_string|mysql_escape_string|escape_string|SQL_EscapeString)\b",
  181. window,
  182. )
  183. )
  184.  
  185.  
  186. def contains_validator(window: str, var_name: str) -> bool:
  187. safe_patterns = [
  188. rf"\bcheck_name\s*\(\s*{re.escape(var_name)}\s*\)",
  189. rf"\bIsValid\w*\s*\(\s*{re.escape(var_name)}\s*\)",
  190. rf"\bValidate\w*\s*\(\s*{re.escape(var_name)}\s*\)",
  191. ]
  192. return any(re.search(p, window) for p in safe_patterns)
  193.  
  194.  
  195. def lua_string_vars(window: str) -> set[str]:
  196. vars_found: set[str] = set()
  197. patterns = [
  198. r"(?:const\s+)?char\s*\*\s*(\w+)\s*=\s*lua_tostring\s*\(",
  199. r"(?:const\s+)?char\s*\*\s*(\w+)\s*=\s*luaL_checkstring\s*\(",
  200. r"std::string\s+(\w+)\s*=\s*lua_tostring\s*\(",
  201. r"auto\s+(\w+)\s*=\s*lua_tostring\s*\(",
  202. ]
  203. for pattern in patterns:
  204. vars_found.update(re.findall(pattern, window))
  205. return vars_found
  206.  
  207.  
  208. def suspicious_string_args(stmt: str) -> list[str]:
  209. candidates = set()
  210. for token in re.findall(r"\b(?:p->|data->)?(?:sz|s|str|name|flag|quest|hwid)\w*\b", stmt, re.IGNORECASE):
  211. candidates.add(token)
  212. for token in re.findall(r"\b\w+\.(?:c_str)\s*\(\s*\)", stmt):
  213. candidates.add(token)
  214. return sorted(candidates)
  215.  
  216.  
  217. def find_matching_brace(text: str, open_pos: int) -> int:
  218. depth = 0
  219. in_string = False
  220. in_char = False
  221. escape = False
  222. i = open_pos
  223. while i < len(text):
  224. ch = text[i]
  225. if escape:
  226. escape = False
  227. elif ch == "\\":
  228. escape = True
  229. elif in_string:
  230. if ch == '"':
  231. in_string = False
  232. elif in_char:
  233. if ch == "'":
  234. in_char = False
  235. elif ch == '"':
  236. in_string = True
  237. elif ch == "'":
  238. in_char = True
  239. elif ch == "{":
  240. depth += 1
  241. elif ch == "}":
  242. depth -= 1
  243. if depth == 0:
  244. return i
  245. i += 1
  246. return -1
  247.  
  248.  
  249. def find_matching_paren_inline(text: str, open_pos: int) -> int:
  250. depth = 0
  251. in_string = False
  252. in_char = False
  253. escape = False
  254. for i in range(open_pos, len(text)):
  255. ch = text[i]
  256. if escape:
  257. escape = False
  258. elif ch == "\\":
  259. escape = True
  260. elif in_string:
  261. if ch == '"':
  262. in_string = False
  263. elif in_char:
  264. if ch == "'":
  265. in_char = False
  266. elif ch == '"':
  267. in_string = True
  268. elif ch == "'":
  269. in_char = True
  270. elif ch == "(":
  271. depth += 1
  272. elif ch == ")":
  273. depth -= 1
  274. if depth == 0:
  275. return i
  276. return -1
  277.  
  278.  
  279. def has_direct_lookup_deref(line: str) -> bool:
  280. calls = [
  281. "CHARACTER_MANAGER::instance().Find",
  282. "CHARACTER_MANAGER::instance().FindByPID",
  283. "CHARACTER_MANAGER::instance().FindPC",
  284. "ITEM_MANAGER::instance().Find",
  285. "ITEM_MANAGER::instance().FindByVID",
  286. ]
  287. for call in calls:
  288. start = 0
  289. while True:
  290. pos = line.find(call, start)
  291. if pos == -1:
  292. break
  293. open_pos = line.find("(", pos + len(call))
  294. if open_pos == -1:
  295. break
  296. close_pos = find_matching_paren_inline(line, open_pos)
  297. if close_pos != -1 and line[close_pos + 1 :].lstrip().startswith("->"):
  298. return True
  299. start = pos + len(call)
  300. return False
  301.  
  302.  
  303. def audit_file(path: Path, root: Path) -> list[Finding]:
  304. text = read_text(path)
  305. if not text:
  306. return []
  307.  
  308. lines = text.splitlines()
  309. findings: list[Finding] = []
  310.  
  311. # 1) Shell execution.
  312. shell_re = re.compile(
  313. r"\b(system|popen|_popen|ShellExecuteA?|CreateProcessA?|WinExec)\s*\(([^;\n]*)",
  314. re.IGNORECASE,
  315. )
  316. for match in shell_re.finditer(text):
  317. line = line_number(text, match.start())
  318. func = match.group(1)
  319. arg = match.group(2).strip()
  320. fixed = looks_like_fixed_literal(arg)
  321. severity = "MEDIUM" if fixed else "HIGH"
  322. confidence = "HIGH" if fixed else "MEDIUM"
  323. title = "Fixed shell command" if fixed else "Dynamic shell execution"
  324. add_finding(
  325. findings,
  326. "SHELL_EXEC",
  327. severity,
  328. confidence,
  329. title,
  330. path,
  331. line,
  332. context(lines, line),
  333. f"{func}() executes an OS command. Fixed literals are usually less risky, dynamic arguments can become command injection.",
  334. "Prefer native filesystem/process APIs. If this is unavoidable, keep arguments constant and not player/config controlled.",
  335. )
  336.  
  337. # 2) Hardcoded names tied to GM/admin logic.
  338. hardcoded_name_re = re.compile(
  339. r'(GetName\s*\(\s*\)\s*==\s*"[^"]+"|strcmp\s*\([^;\n]*GetName\s*\(\s*\)[^;\n]*"[^"]+")'
  340. )
  341. for match in hardcoded_name_re.finditer(text):
  342. line = line_number(text, match.start())
  343. window = nearby_text(lines, line, before=8, after=12)
  344. severity = "HIGH" if re.search(r"\b(GM|admin|command|priv|authority|implementor)\b", window, re.I) else "MEDIUM"
  345. add_finding(
  346. findings,
  347. "HARDCODED_NAME_CHECK",
  348. severity,
  349. "MEDIUM",
  350. "Hardcoded character name check",
  351. path,
  352. line,
  353. context(lines, line),
  354. "A literal character name is compared in code. This can be normal lookup code, but near permissions it is a classic backdoor shape.",
  355. "Verify the name check is not granting permissions or skipping restrictions. Prefer account/GM tables.",
  356. )
  357.  
  358. # 3) Raw SQL directly exposed to Lua.
  359. raw_lua_sql_re = re.compile(
  360. r"DirectQuery\s*\(\s*\"%s\"\s*,\s*(lua_tostring|luaL_checkstring)\s*\(",
  361. re.MULTILINE,
  362. )
  363. for match in raw_lua_sql_re.finditer(text):
  364. line = line_number(text, match.start())
  365. add_finding(
  366. findings,
  367. "LUA_DIRECT_RAW_SQL",
  368. "CRITICAL",
  369. "HIGH",
  370. "Lua can pass raw SQL to DirectQuery",
  371. path,
  372. line,
  373. context(lines, line),
  374. "A quest/Lua string is executed as SQL. Safe only if every caller is trusted and never includes player input.",
  375. "Remove the generic raw query API or restrict it to admin-only/server-only scripts. Prefer narrow helper functions with escaping.",
  376. )
  377.  
  378. # 4) SQL snprintf with string placeholders.
  379. for idx, line_text in enumerate(lines, start=1):
  380. if "snprintf" not in line_text and "sprintf" not in line_text:
  381. continue
  382. stmt = statement_from(lines, idx)
  383. if "%s" not in stmt or not SQL_KEYWORDS.search(stmt):
  384. continue
  385. window = nearby_text(lines, idx)
  386. if contains_escape(window):
  387. continue
  388.  
  389. vars_from_lua = lua_string_vars(window)
  390. used_lua_vars = [name for name in vars_from_lua if re.search(rf"\b{re.escape(name)}\b", stmt)]
  391. string_args = suspicious_string_args(stmt)
  392.  
  393. if used_lua_vars:
  394. validated = any(contains_validator(window, name) for name in used_lua_vars)
  395. severity = "MEDIUM" if validated else "HIGH"
  396. confidence = "MEDIUM" if validated else "HIGH"
  397. title = "Lua string used in SQL without visible escaping"
  398. why = "A lua_tostring/luaL_checkstring value is inserted into an SQL format string. A nearby validator lowers risk but is not the same as SQL escaping."
  399. elif string_args:
  400. severity = "MEDIUM"
  401. confidence = "MEDIUM"
  402. title = "String value used in SQL without visible escaping"
  403. why = "A string-like value is inserted into an SQL format string and no escaping call was visible nearby. This may be safe if the field is strictly validated upstream."
  404. else:
  405. severity = "LOW"
  406. confidence = "LOW"
  407. title = "SQL string formatting with %s"
  408. why = "The SQL statement contains %s placeholders. Review whether each string argument is escaped or strictly validated."
  409.  
  410. add_finding(
  411. findings,
  412. "SQL_STRING_FORMAT_REVIEW",
  413. severity,
  414. confidence,
  415. title,
  416. path,
  417. idx,
  418. context(lines, idx),
  419. why,
  420. "Escape SQL strings with the project DB escape helper, or use a narrow helper that only accepts numeric IDs/enums.",
  421. )
  422.  
  423. # 5) Direct dereference of manager lookups.
  424. for idx, line_text in enumerate(lines, start=1):
  425. if not has_direct_lookup_deref(line_text):
  426. continue
  427. add_finding(
  428. findings,
  429. "DIRECT_LOOKUP_DEREF",
  430. "HIGH",
  431. "MEDIUM",
  432. "Manager lookup dereferenced directly",
  433. path,
  434. idx,
  435. context(lines, idx),
  436. "A lookup result is dereferenced immediately. If the object is missing, this can crash the game process.",
  437. "Store the lookup result in a local pointer and check it before dereferencing.",
  438. )
  439.  
  440. # 6) Unsafe C string functions.
  441. unsafe_re = re.compile(r"\b(strcpy|strcat|sprintf|vsprintf|gets)\s*\(")
  442. for match in unsafe_re.finditer(text):
  443. line = line_number(text, match.start())
  444. func = match.group(1)
  445. add_finding(
  446. findings,
  447. "UNSAFE_C_STRING",
  448. "MEDIUM" if func != "gets" else "HIGH",
  449. "MEDIUM",
  450. f"Unsafe C string function: {func}",
  451. path,
  452. line,
  453. context(lines, line),
  454. f"{func}() does not reliably enforce destination size.",
  455. "Use snprintf, strlcpy/strlcat, or std::string depending on local project style.",
  456. )
  457.  
  458. # 7) Quest/Lua exposed blocks with dangerous sinks.
  459. alua_re = re.compile(r"ALUA\s*\(\s*([A-Za-z0-9_]+)\s*\)\s*\{")
  460. for match in alua_re.finditer(text):
  461. open_brace = text.find("{", match.end() - 1)
  462. close_brace = find_matching_brace(text, open_brace)
  463. if close_brace == -1:
  464. continue
  465. block = text[open_brace : close_brace + 1]
  466. line = line_number(text, match.start())
  467. name = match.group(1)
  468. dangerous = []
  469. if "DirectQuery" in block and re.search(r"lua_tostring|luaL_checkstring", block):
  470. dangerous.append("SQL from Lua string")
  471. if re.search(r"\bSetGMLevel\s*\(", block):
  472. dangerous.append("GM level change/refresh")
  473. if re.search(r"\binterpret_command\s*\(|\bdo_command\s*\(", block):
  474. dangerous.append("server command execution")
  475. if re.search(r"\bDisconnect\s*\(\s*lua_tostring", block):
  476. dangerous.append("disconnect target from Lua string")
  477.  
  478. if dangerous:
  479. severity = "HIGH" if any(x in dangerous for x in ["SQL from Lua string", "server command execution"]) else "MEDIUM"
  480. add_finding(
  481. findings,
  482. "QUEST_API_DANGEROUS_SINK",
  483. severity,
  484. "MEDIUM",
  485. f"Quest API exposes risky sink: {name}",
  486. path,
  487. line,
  488. context(lines, line),
  489. "This ALUA function exposes: " + ", ".join(dangerous) + ". This is safe only when quests are trusted and arguments cannot come from players.",
  490. "Prefer narrow, validated quest APIs. Avoid raw command/SQL passthrough from Lua.",
  491. )
  492.  
  493. # 8) Suspicious plaintext credentials/tokens.
  494. secret_re = re.compile(
  495. r"\b(password|passwd|pwd|secret|token|api[_-]?key)\b\s*[:=]\s*['\"][^'\"]{4,}['\"]",
  496. re.IGNORECASE,
  497. )
  498. for match in secret_re.finditer(text):
  499. line = line_number(text, match.start())
  500. add_finding(
  501. findings,
  502. "PLAINTEXT_SECRET",
  503. "HIGH",
  504. "LOW",
  505. "Possible plaintext secret",
  506. path,
  507. line,
  508. context(lines, line),
  509. "A credential-like key appears to be assigned a literal value. This can be a false positive in config examples.",
  510. "Move real secrets out of source control and rotate them if they were exposed.",
  511. )
  512.  
  513. return findings
  514.  
  515.  
  516. def severity_at_least(severity: str, minimum: str) -> bool:
  517. return SEVERITY_ORDER[severity] >= SEVERITY_ORDER[minimum]
  518.  
  519.  
  520. def write_reports(findings: list[Finding], root: Path, out_dir: Path, min_severity: str) -> tuple[Path, Path]:
  521. ts = int(time.time())
  522. txt_path = out_dir / f"SOURCE_AUDIT_DEEP_{ts}.txt"
  523. json_path = out_dir / f"SOURCE_AUDIT_DEEP_{ts}.json"
  524.  
  525. visible = [f for f in findings if severity_at_least(f.severity, min_severity)]
  526. counts = {sev: 0 for sev in SEVERITY_ORDER}
  527. for finding in visible:
  528. counts[finding.severity] += 1
  529.  
  530. with txt_path.open("w", encoding="utf-8") as f:
  531. f.write("DEEP SOURCE AUDIT REPORT\n")
  532. f.write(f"ROOT: {root}\n")
  533. f.write(f"TIMESTAMP: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
  534. f.write(f"MIN SEVERITY: {min_severity}\n")
  535. f.write(f"TOTAL FINDINGS: {len(visible)}\n")
  536. f.write("COUNTS: " + ", ".join(f"{k}={v}" for k, v in counts.items() if v) + "\n")
  537. f.write("=" * 100 + "\n\n")
  538.  
  539. for item in visible:
  540. f.write(f"[{item.severity}] [{item.confidence} confidence] {item.title}\n")
  541. f.write(f"RULE: {item.rule_id}\n")
  542. f.write(f"FILE: {item.file}\n")
  543. f.write(f"LINE: {item.line}\n")
  544. f.write("WHY: " + item.why + "\n")
  545. f.write("FIX: " + item.remediation + "\n")
  546. f.write("CODE:\n")
  547. f.write(item.evidence + "\n")
  548. f.write("-" * 100 + "\n\n")
  549.  
  550. with json_path.open("w", encoding="utf-8") as f:
  551. json.dump(
  552. {
  553. "root": str(root),
  554. "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
  555. "min_severity": min_severity,
  556. "total_findings": len(visible),
  557. "counts": counts,
  558. "findings": [asdict(item) for item in visible],
  559. },
  560. f,
  561. indent=2,
  562. )
  563.  
  564. return txt_path, json_path
  565.  
  566.  
  567. def main() -> int:
  568. parser = argparse.ArgumentParser(description="Read-only deep source audit scanner.")
  569. parser.add_argument("source", help="Path to source tree")
  570. parser.add_argument(
  571. "--min",
  572. default="LOW",
  573. choices=sorted(SEVERITY_ORDER, key=lambda k: SEVERITY_ORDER[k]),
  574. help="Minimum severity to include in reports",
  575. )
  576. parser.add_argument(
  577. "--out-dir",
  578. default=str(Path(__file__).resolve().parent),
  579. help="Directory where TXT and JSON reports will be written",
  580. )
  581. args = parser.parse_args()
  582.  
  583. root = Path(args.source).expanduser().resolve()
  584. out_dir = Path(args.out_dir).expanduser().resolve()
  585. min_severity = args.min.upper()
  586.  
  587. if not root.exists() or not root.is_dir():
  588. print(f"[ERROR] Invalid source path: {root}")
  589. return 2
  590.  
  591. out_dir.mkdir(parents=True, exist_ok=True)
  592.  
  593. print(f"[INFO] Scanning: {root}")
  594. print(f"[INFO] Minimum severity in report: {min_severity}")
  595.  
  596. all_findings: list[Finding] = []
  597. file_count = 0
  598. started = time.time()
  599. for file_path in iter_files(root):
  600. file_count += 1
  601. all_findings.extend(audit_file(file_path, root))
  602.  
  603. all_findings.sort(
  604. key=lambda f: (
  605. -SEVERITY_ORDER[f.severity],
  606. f.file.lower(),
  607. f.line,
  608. f.rule_id,
  609. )
  610. )
  611.  
  612. txt_path, json_path = write_reports(all_findings, root, out_dir, min_severity)
  613. visible = [f for f in all_findings if severity_at_least(f.severity, min_severity)]
  614. duration = time.time() - started
  615.  
  616. counts: dict[str, int] = {}
  617. for finding in visible:
  618. counts[finding.severity] = counts.get(finding.severity, 0) + 1
  619.  
  620. print(f"[DONE] Files scanned: {file_count}")
  621. print(f"[DONE] Findings in report: {len(visible)}")
  622. print("[DONE] Counts: " + (", ".join(f"{k}={counts[k]}" for k in sorted(counts, key=lambda k: -SEVERITY_ORDER[k])) or "none"))
  623. print(f"[DONE] Duration: {duration:.2f}s")
  624. print(f"[DONE] TXT report: {txt_path}")
  625. print(f"[DONE] JSON report: {json_path}")
  626.  
  627. print("\nTop findings:")
  628. for item in visible[:12]:
  629. print(f" [{item.severity}] {item.file}:{item.line} - {item.title}")
  630.  
  631. return 0
  632.  
  633.  
  634. if __name__ == "__main__":
  635. raise SystemExit(main())
  636.  
Advertisement
Add Comment
Please, Sign In to add comment