Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """
- Deep source audit helper for Metin2 server sources.
- Read-only scanner. It does not modify the source tree.
- Usage:
- python deep_source_audit.py "H:\\Project\\sources\\Server"
- python deep_source_audit.py "H:\\Project\\sources\\Server" --min MEDIUM
- """
- from __future__ import annotations
- import argparse
- import json
- import os
- import re
- import sys
- import time
- from dataclasses import asdict, dataclass
- from pathlib import Path
- from typing import Iterable
- SEVERITY_ORDER = {
- "INFO": 0,
- "LOW": 1,
- "MEDIUM": 2,
- "HIGH": 3,
- "CRITICAL": 4,
- }
- SCAN_EXTENSIONS = {
- ".c",
- ".cc",
- ".cpp",
- ".cxx",
- ".h",
- ".hh",
- ".hpp",
- ".lua",
- ".py",
- ".sql",
- ".txt",
- ".json",
- ".xml",
- ".conf",
- ".config",
- }
- SKIP_DIRS = {
- ".git",
- ".svn",
- ".hg",
- ".vs",
- ".vscode",
- "__pycache__",
- "build",
- "cmake-build-debug",
- "cmake-build-release",
- "debug",
- "release",
- "obj",
- "bin",
- "lib",
- "libs",
- "extern",
- "external",
- "thirdparty",
- "third_party",
- }
- SQL_KEYWORDS = re.compile(
- r"\b(SELECT|INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|TRUNCATE)\b",
- re.IGNORECASE,
- )
- @dataclass
- class Finding:
- rule_id: str
- severity: str
- confidence: str
- title: str
- file: str
- line: int
- evidence: str
- why: str
- remediation: str
- def read_text(path: Path) -> str:
- try:
- return path.read_text(encoding="utf-8", errors="ignore")
- except OSError:
- return ""
- def iter_files(root: Path) -> Iterable[Path]:
- for dirpath, dirnames, filenames in os.walk(root):
- dirnames[:] = [d for d in dirnames if d.lower() not in SKIP_DIRS]
- for name in filenames:
- path = Path(dirpath) / name
- if path.suffix.lower() in SCAN_EXTENSIONS:
- yield path
- def line_number(text: str, offset: int) -> int:
- return text.count("\n", 0, offset) + 1
- def get_line(lines: list[str], line: int) -> str:
- if 1 <= line <= len(lines):
- return lines[line - 1].rstrip("\n")
- return ""
- def context(lines: list[str], line: int, radius: int = 2) -> str:
- start = max(1, line - radius)
- end = min(len(lines), line + radius)
- out = []
- for num in range(start, end + 1):
- marker = ">" if num == line else " "
- out.append(f"{marker}{num:5}: {get_line(lines, num)}")
- return "\n".join(out)
- def statement_from(lines: list[str], start_line: int, max_lines: int = 8) -> str:
- parts = []
- for i in range(start_line - 1, min(len(lines), start_line - 1 + max_lines)):
- parts.append(lines[i].rstrip("\n"))
- if ";" in lines[i]:
- break
- return "\n".join(parts)
- def nearby_text(lines: list[str], line: int, before: int = 30, after: int = 8) -> str:
- start = max(1, line - before)
- end = min(len(lines), line + after)
- return "\n".join(lines[start - 1 : end])
- def add_finding(
- findings: list[Finding],
- rule_id: str,
- severity: str,
- confidence: str,
- title: str,
- file: Path,
- line: int,
- evidence: str,
- why: str,
- remediation: str,
- ) -> None:
- findings.append(
- Finding(
- rule_id=rule_id,
- severity=severity,
- confidence=confidence,
- title=title,
- file=str(file),
- line=line,
- evidence=evidence.strip(),
- why=why.strip(),
- remediation=remediation.strip(),
- )
- )
- def looks_like_fixed_literal(arg: str) -> bool:
- arg = arg.strip()
- if arg.endswith(")"):
- arg = arg[:-1].strip()
- return bool(re.fullmatch(r'L?"[^"]*"\s*', arg))
- def contains_escape(window: str) -> bool:
- return bool(
- re.search(
- r"\b(EscapeString|mysql_real_escape_string|mysql_escape_string|escape_string|SQL_EscapeString)\b",
- window,
- )
- )
- def contains_validator(window: str, var_name: str) -> bool:
- safe_patterns = [
- rf"\bcheck_name\s*\(\s*{re.escape(var_name)}\s*\)",
- rf"\bIsValid\w*\s*\(\s*{re.escape(var_name)}\s*\)",
- rf"\bValidate\w*\s*\(\s*{re.escape(var_name)}\s*\)",
- ]
- return any(re.search(p, window) for p in safe_patterns)
- def lua_string_vars(window: str) -> set[str]:
- vars_found: set[str] = set()
- patterns = [
- r"(?:const\s+)?char\s*\*\s*(\w+)\s*=\s*lua_tostring\s*\(",
- r"(?:const\s+)?char\s*\*\s*(\w+)\s*=\s*luaL_checkstring\s*\(",
- r"std::string\s+(\w+)\s*=\s*lua_tostring\s*\(",
- r"auto\s+(\w+)\s*=\s*lua_tostring\s*\(",
- ]
- for pattern in patterns:
- vars_found.update(re.findall(pattern, window))
- return vars_found
- def suspicious_string_args(stmt: str) -> list[str]:
- candidates = set()
- for token in re.findall(r"\b(?:p->|data->)?(?:sz|s|str|name|flag|quest|hwid)\w*\b", stmt, re.IGNORECASE):
- candidates.add(token)
- for token in re.findall(r"\b\w+\.(?:c_str)\s*\(\s*\)", stmt):
- candidates.add(token)
- return sorted(candidates)
- def find_matching_brace(text: str, open_pos: int) -> int:
- depth = 0
- in_string = False
- in_char = False
- escape = False
- i = open_pos
- while i < len(text):
- ch = text[i]
- if escape:
- escape = False
- elif ch == "\\":
- escape = True
- elif in_string:
- if ch == '"':
- in_string = False
- elif in_char:
- if ch == "'":
- in_char = False
- elif ch == '"':
- in_string = True
- elif ch == "'":
- in_char = True
- elif ch == "{":
- depth += 1
- elif ch == "}":
- depth -= 1
- if depth == 0:
- return i
- i += 1
- return -1
- def find_matching_paren_inline(text: str, open_pos: int) -> int:
- depth = 0
- in_string = False
- in_char = False
- escape = False
- for i in range(open_pos, len(text)):
- ch = text[i]
- if escape:
- escape = False
- elif ch == "\\":
- escape = True
- elif in_string:
- if ch == '"':
- in_string = False
- elif in_char:
- if ch == "'":
- in_char = False
- elif ch == '"':
- in_string = True
- elif ch == "'":
- in_char = True
- elif ch == "(":
- depth += 1
- elif ch == ")":
- depth -= 1
- if depth == 0:
- return i
- return -1
- def has_direct_lookup_deref(line: str) -> bool:
- calls = [
- "CHARACTER_MANAGER::instance().Find",
- "CHARACTER_MANAGER::instance().FindByPID",
- "CHARACTER_MANAGER::instance().FindPC",
- "ITEM_MANAGER::instance().Find",
- "ITEM_MANAGER::instance().FindByVID",
- ]
- for call in calls:
- start = 0
- while True:
- pos = line.find(call, start)
- if pos == -1:
- break
- open_pos = line.find("(", pos + len(call))
- if open_pos == -1:
- break
- close_pos = find_matching_paren_inline(line, open_pos)
- if close_pos != -1 and line[close_pos + 1 :].lstrip().startswith("->"):
- return True
- start = pos + len(call)
- return False
- def audit_file(path: Path, root: Path) -> list[Finding]:
- text = read_text(path)
- if not text:
- return []
- lines = text.splitlines()
- findings: list[Finding] = []
- # 1) Shell execution.
- shell_re = re.compile(
- r"\b(system|popen|_popen|ShellExecuteA?|CreateProcessA?|WinExec)\s*\(([^;\n]*)",
- re.IGNORECASE,
- )
- for match in shell_re.finditer(text):
- line = line_number(text, match.start())
- func = match.group(1)
- arg = match.group(2).strip()
- fixed = looks_like_fixed_literal(arg)
- severity = "MEDIUM" if fixed else "HIGH"
- confidence = "HIGH" if fixed else "MEDIUM"
- title = "Fixed shell command" if fixed else "Dynamic shell execution"
- add_finding(
- findings,
- "SHELL_EXEC",
- severity,
- confidence,
- title,
- path,
- line,
- context(lines, line),
- f"{func}() executes an OS command. Fixed literals are usually less risky, dynamic arguments can become command injection.",
- "Prefer native filesystem/process APIs. If this is unavoidable, keep arguments constant and not player/config controlled.",
- )
- # 2) Hardcoded names tied to GM/admin logic.
- hardcoded_name_re = re.compile(
- r'(GetName\s*\(\s*\)\s*==\s*"[^"]+"|strcmp\s*\([^;\n]*GetName\s*\(\s*\)[^;\n]*"[^"]+")'
- )
- for match in hardcoded_name_re.finditer(text):
- line = line_number(text, match.start())
- window = nearby_text(lines, line, before=8, after=12)
- severity = "HIGH" if re.search(r"\b(GM|admin|command|priv|authority|implementor)\b", window, re.I) else "MEDIUM"
- add_finding(
- findings,
- "HARDCODED_NAME_CHECK",
- severity,
- "MEDIUM",
- "Hardcoded character name check",
- path,
- line,
- context(lines, line),
- "A literal character name is compared in code. This can be normal lookup code, but near permissions it is a classic backdoor shape.",
- "Verify the name check is not granting permissions or skipping restrictions. Prefer account/GM tables.",
- )
- # 3) Raw SQL directly exposed to Lua.
- raw_lua_sql_re = re.compile(
- r"DirectQuery\s*\(\s*\"%s\"\s*,\s*(lua_tostring|luaL_checkstring)\s*\(",
- re.MULTILINE,
- )
- for match in raw_lua_sql_re.finditer(text):
- line = line_number(text, match.start())
- add_finding(
- findings,
- "LUA_DIRECT_RAW_SQL",
- "CRITICAL",
- "HIGH",
- "Lua can pass raw SQL to DirectQuery",
- path,
- line,
- context(lines, line),
- "A quest/Lua string is executed as SQL. Safe only if every caller is trusted and never includes player input.",
- "Remove the generic raw query API or restrict it to admin-only/server-only scripts. Prefer narrow helper functions with escaping.",
- )
- # 4) SQL snprintf with string placeholders.
- for idx, line_text in enumerate(lines, start=1):
- if "snprintf" not in line_text and "sprintf" not in line_text:
- continue
- stmt = statement_from(lines, idx)
- if "%s" not in stmt or not SQL_KEYWORDS.search(stmt):
- continue
- window = nearby_text(lines, idx)
- if contains_escape(window):
- continue
- vars_from_lua = lua_string_vars(window)
- used_lua_vars = [name for name in vars_from_lua if re.search(rf"\b{re.escape(name)}\b", stmt)]
- string_args = suspicious_string_args(stmt)
- if used_lua_vars:
- validated = any(contains_validator(window, name) for name in used_lua_vars)
- severity = "MEDIUM" if validated else "HIGH"
- confidence = "MEDIUM" if validated else "HIGH"
- title = "Lua string used in SQL without visible escaping"
- 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."
- elif string_args:
- severity = "MEDIUM"
- confidence = "MEDIUM"
- title = "String value used in SQL without visible escaping"
- 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."
- else:
- severity = "LOW"
- confidence = "LOW"
- title = "SQL string formatting with %s"
- why = "The SQL statement contains %s placeholders. Review whether each string argument is escaped or strictly validated."
- add_finding(
- findings,
- "SQL_STRING_FORMAT_REVIEW",
- severity,
- confidence,
- title,
- path,
- idx,
- context(lines, idx),
- why,
- "Escape SQL strings with the project DB escape helper, or use a narrow helper that only accepts numeric IDs/enums.",
- )
- # 5) Direct dereference of manager lookups.
- for idx, line_text in enumerate(lines, start=1):
- if not has_direct_lookup_deref(line_text):
- continue
- add_finding(
- findings,
- "DIRECT_LOOKUP_DEREF",
- "HIGH",
- "MEDIUM",
- "Manager lookup dereferenced directly",
- path,
- idx,
- context(lines, idx),
- "A lookup result is dereferenced immediately. If the object is missing, this can crash the game process.",
- "Store the lookup result in a local pointer and check it before dereferencing.",
- )
- # 6) Unsafe C string functions.
- unsafe_re = re.compile(r"\b(strcpy|strcat|sprintf|vsprintf|gets)\s*\(")
- for match in unsafe_re.finditer(text):
- line = line_number(text, match.start())
- func = match.group(1)
- add_finding(
- findings,
- "UNSAFE_C_STRING",
- "MEDIUM" if func != "gets" else "HIGH",
- "MEDIUM",
- f"Unsafe C string function: {func}",
- path,
- line,
- context(lines, line),
- f"{func}() does not reliably enforce destination size.",
- "Use snprintf, strlcpy/strlcat, or std::string depending on local project style.",
- )
- # 7) Quest/Lua exposed blocks with dangerous sinks.
- alua_re = re.compile(r"ALUA\s*\(\s*([A-Za-z0-9_]+)\s*\)\s*\{")
- for match in alua_re.finditer(text):
- open_brace = text.find("{", match.end() - 1)
- close_brace = find_matching_brace(text, open_brace)
- if close_brace == -1:
- continue
- block = text[open_brace : close_brace + 1]
- line = line_number(text, match.start())
- name = match.group(1)
- dangerous = []
- if "DirectQuery" in block and re.search(r"lua_tostring|luaL_checkstring", block):
- dangerous.append("SQL from Lua string")
- if re.search(r"\bSetGMLevel\s*\(", block):
- dangerous.append("GM level change/refresh")
- if re.search(r"\binterpret_command\s*\(|\bdo_command\s*\(", block):
- dangerous.append("server command execution")
- if re.search(r"\bDisconnect\s*\(\s*lua_tostring", block):
- dangerous.append("disconnect target from Lua string")
- if dangerous:
- severity = "HIGH" if any(x in dangerous for x in ["SQL from Lua string", "server command execution"]) else "MEDIUM"
- add_finding(
- findings,
- "QUEST_API_DANGEROUS_SINK",
- severity,
- "MEDIUM",
- f"Quest API exposes risky sink: {name}",
- path,
- line,
- context(lines, line),
- "This ALUA function exposes: " + ", ".join(dangerous) + ". This is safe only when quests are trusted and arguments cannot come from players.",
- "Prefer narrow, validated quest APIs. Avoid raw command/SQL passthrough from Lua.",
- )
- # 8) Suspicious plaintext credentials/tokens.
- secret_re = re.compile(
- r"\b(password|passwd|pwd|secret|token|api[_-]?key)\b\s*[:=]\s*['\"][^'\"]{4,}['\"]",
- re.IGNORECASE,
- )
- for match in secret_re.finditer(text):
- line = line_number(text, match.start())
- add_finding(
- findings,
- "PLAINTEXT_SECRET",
- "HIGH",
- "LOW",
- "Possible plaintext secret",
- path,
- line,
- context(lines, line),
- "A credential-like key appears to be assigned a literal value. This can be a false positive in config examples.",
- "Move real secrets out of source control and rotate them if they were exposed.",
- )
- return findings
- def severity_at_least(severity: str, minimum: str) -> bool:
- return SEVERITY_ORDER[severity] >= SEVERITY_ORDER[minimum]
- def write_reports(findings: list[Finding], root: Path, out_dir: Path, min_severity: str) -> tuple[Path, Path]:
- ts = int(time.time())
- txt_path = out_dir / f"SOURCE_AUDIT_DEEP_{ts}.txt"
- json_path = out_dir / f"SOURCE_AUDIT_DEEP_{ts}.json"
- visible = [f for f in findings if severity_at_least(f.severity, min_severity)]
- counts = {sev: 0 for sev in SEVERITY_ORDER}
- for finding in visible:
- counts[finding.severity] += 1
- with txt_path.open("w", encoding="utf-8") as f:
- f.write("DEEP SOURCE AUDIT REPORT\n")
- f.write(f"ROOT: {root}\n")
- f.write(f"TIMESTAMP: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
- f.write(f"MIN SEVERITY: {min_severity}\n")
- f.write(f"TOTAL FINDINGS: {len(visible)}\n")
- f.write("COUNTS: " + ", ".join(f"{k}={v}" for k, v in counts.items() if v) + "\n")
- f.write("=" * 100 + "\n\n")
- for item in visible:
- f.write(f"[{item.severity}] [{item.confidence} confidence] {item.title}\n")
- f.write(f"RULE: {item.rule_id}\n")
- f.write(f"FILE: {item.file}\n")
- f.write(f"LINE: {item.line}\n")
- f.write("WHY: " + item.why + "\n")
- f.write("FIX: " + item.remediation + "\n")
- f.write("CODE:\n")
- f.write(item.evidence + "\n")
- f.write("-" * 100 + "\n\n")
- with json_path.open("w", encoding="utf-8") as f:
- json.dump(
- {
- "root": str(root),
- "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
- "min_severity": min_severity,
- "total_findings": len(visible),
- "counts": counts,
- "findings": [asdict(item) for item in visible],
- },
- f,
- indent=2,
- )
- return txt_path, json_path
- def main() -> int:
- parser = argparse.ArgumentParser(description="Read-only deep source audit scanner.")
- parser.add_argument("source", help="Path to source tree")
- parser.add_argument(
- "--min",
- default="LOW",
- choices=sorted(SEVERITY_ORDER, key=lambda k: SEVERITY_ORDER[k]),
- help="Minimum severity to include in reports",
- )
- parser.add_argument(
- "--out-dir",
- default=str(Path(__file__).resolve().parent),
- help="Directory where TXT and JSON reports will be written",
- )
- args = parser.parse_args()
- root = Path(args.source).expanduser().resolve()
- out_dir = Path(args.out_dir).expanduser().resolve()
- min_severity = args.min.upper()
- if not root.exists() or not root.is_dir():
- print(f"[ERROR] Invalid source path: {root}")
- return 2
- out_dir.mkdir(parents=True, exist_ok=True)
- print(f"[INFO] Scanning: {root}")
- print(f"[INFO] Minimum severity in report: {min_severity}")
- all_findings: list[Finding] = []
- file_count = 0
- started = time.time()
- for file_path in iter_files(root):
- file_count += 1
- all_findings.extend(audit_file(file_path, root))
- all_findings.sort(
- key=lambda f: (
- -SEVERITY_ORDER[f.severity],
- f.file.lower(),
- f.line,
- f.rule_id,
- )
- )
- txt_path, json_path = write_reports(all_findings, root, out_dir, min_severity)
- visible = [f for f in all_findings if severity_at_least(f.severity, min_severity)]
- duration = time.time() - started
- counts: dict[str, int] = {}
- for finding in visible:
- counts[finding.severity] = counts.get(finding.severity, 0) + 1
- print(f"[DONE] Files scanned: {file_count}")
- print(f"[DONE] Findings in report: {len(visible)}")
- print("[DONE] Counts: " + (", ".join(f"{k}={counts[k]}" for k in sorted(counts, key=lambda k: -SEVERITY_ORDER[k])) or "none"))
- print(f"[DONE] Duration: {duration:.2f}s")
- print(f"[DONE] TXT report: {txt_path}")
- print(f"[DONE] JSON report: {json_path}")
- print("\nTop findings:")
- for item in visible[:12]:
- print(f" [{item.severity}] {item.file}:{item.line} - {item.title}")
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
Advertisement
Add Comment
Please, Sign In to add comment