Skip to content

Commit df7400c

Browse files
committed
v2: Add libclang-based type-aware direct access audit
scripts/audit_direct_access_clang.py uses libclang to parse C++ source files with full type information and find MemberExpr nodes where the base expression's type is a guarded struct. Unlike the regex-based audit_direct_access.py, this eliminates false positives from generic field names (name, size, type, info, etc.) by checking the actual resolved type of the expression. Requires: pip install libclang Extracts include paths and defines from the MSVC vcxproj automatically. Tested on PluginLoader.cpp: correctly identifies REManagedObject:: referenceCount, REType::name, REFieldList::deserializer, FunctionDescriptor:: functionPtr, VariableDescriptor::function — and correctly skips `name` and `size` on non-guarded structs that the regex script would flag as false positives. Both scripts coexist: audit_direct_access.py — fast regex, no dependencies, CI-ready audit_direct_access_clang.py — precise, needs libclang, developer tool
1 parent c64cfec commit df7400c

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""
2+
audit_direct_access_clang.py — libclang-based type-aware audit for direct struct field access.
3+
4+
Unlike the regex-based audit_direct_access.py, this script uses libclang to parse C++ source
5+
files with full type information. It identifies MemberExpr nodes where the base expression's
6+
type is a guarded struct, and the accessed member is a field that should go through an accessor.
7+
8+
This eliminates false positives from generic field names (name, size, type, etc.) by checking
9+
the actual type of the expression.
10+
11+
Requirements:
12+
pip install libclang
13+
14+
Usage:
15+
python scripts/audit_direct_access_clang.py [file ...]
16+
17+
If no files given, scans a default set of high-value targets.
18+
"""
19+
import argparse
20+
import json
21+
import os
22+
import sys
23+
import xml.etree.ElementTree as ET
24+
from collections import defaultdict
25+
from pathlib import Path
26+
27+
try:
28+
import clang.cindex as ci
29+
except ImportError:
30+
print("ERROR: pip install libclang", file=sys.stderr)
31+
sys.exit(2)
32+
33+
# ============================================================================
34+
# Configuration
35+
# ============================================================================
36+
37+
REPO_ROOT = Path(__file__).resolve().parent.parent
38+
39+
GUARDED_TYPES = {
40+
"REGameObject": {
41+
"transform", "folder", "name", "shouldUpdate", "shouldDraw",
42+
"shouldUpdateSelf", "shouldDrawSelf", "shouldSelect", "timescale",
43+
"pad_0010", "pad_0017",
44+
},
45+
"REComponent": {
46+
"ownerGameObject", "childComponent", "prevComponent", "nextComponent",
47+
},
48+
"REManagedObject": {
49+
"info", "referenceCount",
50+
},
51+
"REFieldList": {
52+
"unknown", "next", "methods", "num", "maxItems", "variables", "deserializer",
53+
},
54+
"VariableDescriptor": {
55+
"name", "nameHash", "flags1", "function", "flags", "typeFqn", "typeName",
56+
"variableType", "staticVariableData", "size", "getter", "setter", "attributes",
57+
},
58+
"FunctionDescriptor": {
59+
"name", "nameHash", "functionPtr",
60+
},
61+
"REType": {
62+
"name", "size", "typeCRC", "super", "childType", "chainType",
63+
"fields", "classInfo", "type_flags", "methods", "type",
64+
"objectFlags", "objectType",
65+
},
66+
"TargetState": {"m_desc"},
67+
"RenderTargetView": {"m_desc"},
68+
"Buffer": {"m_size_in_bytes", "m_usage_type", "m_option_flags"},
69+
}
70+
71+
# Flatten for quick lookup: field_name -> set of parent type names
72+
FIELD_TO_TYPES = defaultdict(set)
73+
for tname, fields in GUARDED_TYPES.items():
74+
for fname in fields:
75+
FIELD_TO_TYPES[fname].add(tname)
76+
77+
# Files where direct access is expected (accessor implementations)
78+
WHITELIST_SUFFIXES = {
79+
"ReClass_Internal", # all ReClass headers
80+
"REGameObject.cpp",
81+
"REManagedObject.cpp",
82+
"REType.cpp",
83+
"RETypeLayouts.hpp",
84+
"RETypeDefinition.cpp",
85+
"RETypeDefDispatch.hpp",
86+
"RETypeDB.cpp",
87+
"RETypeDB.hpp",
88+
"REComponent.hpp",
89+
"Renderer.hpp",
90+
"Renderer.cpp",
91+
"ViaDispatch.hpp",
92+
"CameraSystemDispatch.hpp",
93+
}
94+
95+
DEFAULT_TARGETS = [
96+
"src/mods/Graphics.cpp",
97+
"src/mods/FirstPerson.cpp",
98+
"src/mods/FreeCam.cpp",
99+
"src/mods/Camera.cpp",
100+
"src/mods/VR.cpp",
101+
"src/mods/Hooks.cpp",
102+
"src/mods/PluginLoader.cpp",
103+
"src/mods/IntegrityCheckBypass.cpp",
104+
"src/mods/ManualFlashlight.cpp",
105+
"src/mods/tools/ObjectExplorer.cpp",
106+
"src/mods/tools/ChainViewer.cpp",
107+
"src/mods/bindings/Sdk.cpp",
108+
"src/mods/vr/games/RE8VR.cpp",
109+
"shared/sdk/REGlobals.cpp",
110+
"shared/sdk/REContext.cpp",
111+
"shared/sdk/RETransform.cpp",
112+
"shared/sdk/RETypes.cpp",
113+
]
114+
115+
116+
def is_whitelisted(filepath):
117+
basename = os.path.basename(filepath)
118+
for suffix in WHITELIST_SUFFIXES:
119+
if suffix in basename:
120+
return True
121+
return False
122+
123+
124+
def get_compile_flags():
125+
"""Extract include paths and defines from the MSVC vcxproj."""
126+
vcxproj = REPO_ROOT / "build" / "REFrameworkSDK.vcxproj"
127+
if not vcxproj.is_file():
128+
# Also check REFramework.vcxproj
129+
vcxproj = REPO_ROOT / "build" / "REFramework.vcxproj"
130+
if not vcxproj.is_file():
131+
print(f"WARNING: No vcxproj found at {vcxproj}, using minimal flags", file=sys.stderr)
132+
return get_minimal_flags()
133+
134+
tree = ET.parse(vcxproj)
135+
root = tree.getroot()
136+
ns = {"ms": "http://schemas.microsoft.com/developer/msbuild/2003"}
137+
138+
includes = []
139+
defines = []
140+
141+
for idg in root.findall(".//ms:ItemDefinitionGroup", ns):
142+
condition = idg.get("Condition", "")
143+
if "RelWithDebInfo" in condition or "Release" in condition:
144+
cc = idg.find("ms:ClCompile", ns)
145+
if cc is not None:
146+
inc_el = cc.find("ms:AdditionalIncludeDirectories", ns)
147+
def_el = cc.find("ms:PreprocessorDefinitions", ns)
148+
if inc_el is not None:
149+
for p in inc_el.text.split(";"):
150+
p = p.strip()
151+
if p and not p.startswith("%"):
152+
includes.append(f"-I{p}")
153+
if def_el is not None:
154+
for d in def_el.text.split(";"):
155+
d = d.strip()
156+
if d and not d.startswith("%") and "CMAKE_INTDIR" not in d:
157+
defines.append(f"-D{d}")
158+
break
159+
160+
# Add MSVC compatibility flags for clang
161+
flags = [
162+
"-x", "c++",
163+
"-std=c++20",
164+
"-fms-extensions",
165+
"-fms-compatibility",
166+
"-fdelayed-template-parsing",
167+
"-Wno-everything", # suppress all warnings, we only want AST
168+
]
169+
flags.extend(includes)
170+
flags.extend(defines)
171+
172+
# Add Windows SDK include if available
173+
win_sdk = os.environ.get("WindowsSdkDir", r"C:\Program Files (x86)\Windows Kits\10")
174+
win_sdk_ver = os.environ.get("WindowsSDKVersion", "10.0.22621.0").rstrip("\\")
175+
msvc_dir = os.environ.get("VCToolsInstallDir", r"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207")
176+
177+
for d in [
178+
f"{msvc_dir}\\include",
179+
f"{win_sdk}\\Include\\{win_sdk_ver}\\ucrt",
180+
f"{win_sdk}\\Include\\{win_sdk_ver}\\shared",
181+
f"{win_sdk}\\Include\\{win_sdk_ver}\\um",
182+
]:
183+
if os.path.isdir(d):
184+
flags.append(f"-I{d}")
185+
186+
return flags
187+
188+
189+
def get_minimal_flags():
190+
"""Fallback flags when no vcxproj is available."""
191+
return [
192+
"-x", "c++", "-std=c++20",
193+
"-fms-extensions", "-fms-compatibility",
194+
"-fdelayed-template-parsing",
195+
"-Wno-everything",
196+
"-DREFRAMEWORK_UNIVERSAL",
197+
"-DWIN32", "-D_WINDOWS", "-DNDEBUG",
198+
f"-I{REPO_ROOT / 'shared'}",
199+
f"-I{REPO_ROOT / 'include'}",
200+
f"-I{REPO_ROOT / 'src'}",
201+
f"-I{REPO_ROOT / 'dependencies'}",
202+
]
203+
204+
205+
def get_type_name(cursor):
206+
"""Extract the unqualified struct/class name from a cursor's type."""
207+
t = cursor.type
208+
if t.kind == ci.TypeKind.POINTER:
209+
t = t.get_pointee()
210+
# Strip qualifiers and get the declaration
211+
decl = t.get_declaration()
212+
if decl.kind != ci.CursorKind.NO_DECL_FOUND:
213+
return decl.spelling
214+
# Fallback: parse from the type spelling
215+
spelling = t.spelling.replace("const ", "").replace("class ", "").replace("struct ", "").strip()
216+
if "::" in spelling:
217+
spelling = spelling.split("::")[-1]
218+
if spelling.endswith("*"):
219+
spelling = spelling[:-1].strip()
220+
return spelling
221+
222+
223+
def scan_file(filepath, flags):
224+
"""Parse a file with libclang and find guarded field accesses."""
225+
index = ci.Index.create()
226+
227+
abs_path = str(REPO_ROOT / filepath) if not os.path.isabs(filepath) else filepath
228+
229+
tu = index.parse(abs_path, args=flags,
230+
options=ci.TranslationUnit.PARSE_INCOMPLETE)
231+
232+
if tu is None:
233+
print(f" FAILED TO PARSE: {filepath}", file=sys.stderr)
234+
return [], True
235+
236+
# Count errors for reporting, but don't bail — partial ASTs are still useful.
237+
error_count = sum(1 for d in tu.diagnostics if d.severity >= ci.Diagnostic.Error)
238+
239+
violations = []
240+
241+
def visit(cursor):
242+
# Only look at MemberRefExpr (->field or .field)
243+
if cursor.kind == ci.CursorKind.MEMBER_REF_EXPR:
244+
field_name = cursor.spelling
245+
if field_name in FIELD_TO_TYPES:
246+
# Check the type of the base expression
247+
# The base is the first child of a MemberRefExpr
248+
children = list(cursor.get_children())
249+
if children:
250+
base_type_name = get_type_name(children[0])
251+
if base_type_name in GUARDED_TYPES and field_name in GUARDED_TYPES[base_type_name]:
252+
loc = cursor.location
253+
if loc.file and not is_whitelisted(loc.file.name):
254+
violations.append({
255+
"file": os.path.relpath(loc.file.name, REPO_ROOT).replace("\\", "/"),
256+
"line": loc.line,
257+
"col": loc.column,
258+
"type": base_type_name,
259+
"field": field_name,
260+
})
261+
262+
for child in cursor.get_children():
263+
visit(child)
264+
265+
visit(tu.cursor)
266+
return violations, error_count
267+
268+
269+
def main():
270+
parser = argparse.ArgumentParser(description="libclang-based direct field access audit")
271+
parser.add_argument("files", nargs="*", help="Files to scan (default: high-value targets)")
272+
parser.add_argument("--json", action="store_true")
273+
parser.add_argument("--all", action="store_true", help="Scan all .cpp files in src/ and shared/sdk/")
274+
args = parser.parse_args()
275+
276+
flags = get_compile_flags()
277+
278+
if args.all:
279+
targets = []
280+
for d in ["src", "shared/sdk"]:
281+
for root, _, files in os.walk(REPO_ROOT / d):
282+
for f in files:
283+
if f.endswith(".cpp"):
284+
fp = os.path.relpath(os.path.join(root, f), REPO_ROOT)
285+
if not is_whitelisted(fp):
286+
targets.append(fp)
287+
elif args.files:
288+
targets = args.files
289+
else:
290+
targets = DEFAULT_TARGETS
291+
292+
all_violations = []
293+
294+
for filepath in sorted(targets):
295+
print(f" Scanning {filepath}...", file=sys.stderr, end="", flush=True)
296+
violations, error_count = scan_file(filepath, flags)
297+
if error_count > 0:
298+
print(f" {len(violations)} violations ({error_count} parse errors)", file=sys.stderr)
299+
else:
300+
print(f" {len(violations)} violations", file=sys.stderr)
301+
all_violations.extend(violations)
302+
303+
if args.json:
304+
print(json.dumps({
305+
"total": len(all_violations),
306+
"violations": all_violations,
307+
}, indent=2))
308+
else:
309+
by_file = defaultdict(list)
310+
for v in all_violations:
311+
by_file[v["file"]].append(v)
312+
313+
print(f"\nTotal violations: {len(all_violations)}")
314+
print()
315+
for fp in sorted(by_file):
316+
vv = by_file[fp]
317+
print(f"--- {fp} ({len(vv)}) ---")
318+
for v in vv:
319+
print(f" L{v['line']:>5}:{v['col']:<3} {v['type']}::{v['field']}")
320+
print()
321+
322+
return 1 if all_violations else 0
323+
324+
325+
if __name__ == "__main__":
326+
sys.exit(main())

0 commit comments

Comments
 (0)